Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
23ff4ff86e | ||
|
|
091f78174e | ||
|
|
190fc2e590 | ||
|
|
48bc78fe38 | ||
|
|
abf005f225 | ||
|
|
9de2cb40b4 | ||
|
|
29c67f629d | ||
|
|
0e3502c6f0 | ||
|
|
a1604979f0 | ||
|
|
08221e48de | ||
|
|
42b5cc0c02 | ||
|
|
1717635bfd | ||
|
|
0a5a17402c | ||
|
|
bc0fe9326a |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -40,6 +40,7 @@ backend/uploads/
|
||||
backend/cookies/
|
||||
backend/user_data/
|
||||
backend/debug_screenshots/
|
||||
backend/keys/
|
||||
*_cookies.json
|
||||
|
||||
# ============ 模型权重 ============
|
||||
|
||||
278
Docs/ALIPAY_DEPLOY.md
Normal file
278
Docs/ALIPAY_DEPLOY.md
Normal file
@@ -0,0 +1,278 @@
|
||||
# 支付宝付费开通会员 — 部署指南
|
||||
|
||||
本文档涵盖支付宝电脑网站支付功能的完整部署流程。用户注册后通过支付宝付费自动激活会员,有效期 1 年。
|
||||
|
||||
---
|
||||
|
||||
## 前置条件
|
||||
|
||||
- 支付宝企业/个体商户账号
|
||||
- 已在 [支付宝开放平台](https://open.alipay.com) 创建应用并获取 APPID
|
||||
- 应用已开通 **「电脑网站支付」** 产品权限(`alipay.trade.page.pay` 接口)
|
||||
- 服务器域名已配置 HTTPS(支付宝回调要求公网可达)
|
||||
|
||||
---
|
||||
|
||||
## 第一部分:支付宝开放平台配置
|
||||
|
||||
### 1. 创建应用
|
||||
|
||||
登录 https://open.alipay.com → 控制台 → 创建应用(或使用已有应用)。
|
||||
|
||||
### 2. 开通「电脑网站支付」产品
|
||||
|
||||
进入应用详情 → 产品绑定/产品管理 → 添加 **「电脑网站支付」** → 提交审核。
|
||||
|
||||
> **注意**:未开通此产品会导致 `ACQ.ACCESS_FORBIDDEN` 错误。
|
||||
|
||||
### 3. 生成密钥对
|
||||
|
||||
进入应用详情 → 开发设置 → 接口加签方式 → 选择 **RSA2(SHA256)**:
|
||||
|
||||
1. 使用支付宝官方密钥工具生成 RSA2048 密钥对
|
||||
2. 将 **应用公钥** 上传到开放平台
|
||||
3. 上传后平台会显示 **支付宝公钥**(`alipayPublicKey_RSA2`)
|
||||
|
||||
最终你会得到两样东西:
|
||||
- **应用私钥**:你本地保存,代码用来签名请求
|
||||
- **支付宝公钥**:平台返回给你,代码用来验证回调签名
|
||||
|
||||
> 应用公钥只是上传用的中间产物,代码中不需要。
|
||||
|
||||
---
|
||||
|
||||
## 第二部分:服务器配置
|
||||
|
||||
### 1. 放置密钥文件
|
||||
|
||||
将密钥保存为标准 PEM 格式,放到 `backend/keys/` 目录:
|
||||
|
||||
```bash
|
||||
mkdir -p /home/rongye/ProgramFiles/ViGent2/backend/keys
|
||||
```
|
||||
|
||||
**`backend/keys/app_private_key.pem`**(应用私钥):
|
||||
|
||||
```
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIIEvQIBADANBgkqhkiG9w0BAQEFAASC...(你的私钥内容)
|
||||
...
|
||||
-----END PRIVATE KEY-----
|
||||
```
|
||||
|
||||
**`backend/keys/alipay_public_key.pem`**(支付宝公钥):
|
||||
|
||||
```
|
||||
-----BEGIN PUBLIC KEY-----
|
||||
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A...(支付宝公钥内容)
|
||||
...
|
||||
-----END PUBLIC KEY-----
|
||||
```
|
||||
|
||||
#### PEM 格式要求
|
||||
|
||||
支付宝密钥工具导出的是一行纯文本,需要转换为标准 PEM 格式:
|
||||
|
||||
- 必须有头尾标记(`-----BEGIN/END ...-----`)
|
||||
- 密钥内容每 64 字符换行
|
||||
- 私钥头标记为 `-----BEGIN PRIVATE KEY-----`(PKCS#8 格式)
|
||||
- 公钥头标记为 `-----BEGIN PUBLIC KEY-----`
|
||||
|
||||
如果你拿到的是一行裸密钥,用以下命令转换:
|
||||
|
||||
```bash
|
||||
# 私钥格式化(假设裸密钥在 raw_private.txt 中)
|
||||
echo "-----BEGIN PRIVATE KEY-----" > app_private_key.pem
|
||||
cat raw_private.txt | fold -w 64 >> app_private_key.pem
|
||||
echo "-----END PRIVATE KEY-----" >> app_private_key.pem
|
||||
|
||||
# 公钥格式化
|
||||
echo "-----BEGIN PUBLIC KEY-----" > alipay_public_key.pem
|
||||
cat raw_public.txt | fold -w 64 >> alipay_public_key.pem
|
||||
echo "-----END PUBLIC KEY-----" >> alipay_public_key.pem
|
||||
```
|
||||
|
||||
> `backend/keys/` 目录已加入 `.gitignore`,不会被提交到仓库。
|
||||
|
||||
### 2. 配置环境变量
|
||||
|
||||
在 `backend/.env` 中添加:
|
||||
|
||||
```ini
|
||||
# =============== 支付宝配置 ===============
|
||||
ALIPAY_APP_ID=你的应用APPID
|
||||
ALIPAY_PRIVATE_KEY_PATH=/home/rongye/ProgramFiles/ViGent2/backend/keys/app_private_key.pem
|
||||
ALIPAY_PUBLIC_KEY_PATH=/home/rongye/ProgramFiles/ViGent2/backend/keys/alipay_public_key.pem
|
||||
ALIPAY_NOTIFY_URL=https://vigent.hbyrkj.top/api/payment/notify
|
||||
ALIPAY_RETURN_URL=https://vigent.hbyrkj.top/pay
|
||||
```
|
||||
|
||||
| 变量 | 说明 |
|
||||
|------|------|
|
||||
| `ALIPAY_APP_ID` | 支付宝开放平台应用 APPID |
|
||||
| `ALIPAY_PRIVATE_KEY_PATH` | 应用私钥 PEM 文件绝对路径 |
|
||||
| `ALIPAY_PUBLIC_KEY_PATH` | 支付宝公钥 PEM 文件绝对路径 |
|
||||
| `ALIPAY_NOTIFY_URL` | 异步回调地址(服务器间通信),必须公网 HTTPS 可达 |
|
||||
| `ALIPAY_RETURN_URL` | 同步跳转地址(用户支付完成后浏览器跳转回的页面) |
|
||||
|
||||
`config.py` 中还有几个可调参数(已有默认值,一般不需要加到 .env):
|
||||
|
||||
| 变量 | 默认值 | 说明 |
|
||||
|------|--------|------|
|
||||
| `ALIPAY_SANDBOX` | `false` | 是否使用沙箱环境 |
|
||||
| `PAYMENT_AMOUNT` | `999.00` | 会员价格(元) |
|
||||
| `PAYMENT_EXPIRE_DAYS` | `365` | 会员有效天数 |
|
||||
|
||||
### 3. 创建数据库表
|
||||
|
||||
通过 Docker 在本地 Supabase 中执行:
|
||||
|
||||
```bash
|
||||
docker exec -i supabase-db psql -U postgres -c "
|
||||
CREATE TABLE IF NOT EXISTS orders (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
|
||||
out_trade_no TEXT UNIQUE NOT NULL,
|
||||
amount DECIMAL(10, 2) NOT NULL DEFAULT 999.00,
|
||||
status TEXT DEFAULT 'pending' CHECK (status IN ('pending', 'paid', 'failed')),
|
||||
trade_no TEXT,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
paid_at TIMESTAMP WITH TIME ZONE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_orders_user_id ON orders(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_orders_out_trade_no ON orders(out_trade_no);
|
||||
"
|
||||
```
|
||||
|
||||
### 4. 安装依赖
|
||||
|
||||
```bash
|
||||
# 后端(在 venv 中)
|
||||
cd /home/rongye/ProgramFiles/ViGent2/backend
|
||||
venv/bin/pip install python-alipay-sdk
|
||||
```
|
||||
|
||||
> 前端无额外依赖需要安装。
|
||||
|
||||
### 5. Nginx 配置
|
||||
|
||||
确保 Nginx 将 `/api/payment/notify` 代理到后端。如果现有配置已覆盖 `/api/` 前缀,则无需额外修改:
|
||||
|
||||
```nginx
|
||||
location /api/ {
|
||||
proxy_pass http://localhost:8006;
|
||||
# ... 现有配置
|
||||
}
|
||||
```
|
||||
|
||||
### 6. 重启服务
|
||||
|
||||
```bash
|
||||
# 构建前端
|
||||
cd /home/rongye/ProgramFiles/ViGent2/frontend
|
||||
npx next build
|
||||
|
||||
# 重启
|
||||
pm2 restart vigent2-backend
|
||||
pm2 restart vigent2-frontend
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 第三部分:正式上线
|
||||
|
||||
测试通过后,将 `backend/app/core/config.py` 中的测试金额改为正式价格:
|
||||
|
||||
```python
|
||||
PAYMENT_AMOUNT: float = 999.00 # 正式价格
|
||||
```
|
||||
|
||||
或在 `backend/.env` 中添加覆盖:
|
||||
|
||||
```ini
|
||||
PAYMENT_AMOUNT=999.00
|
||||
```
|
||||
|
||||
然后重启后端:
|
||||
|
||||
```bash
|
||||
pm2 restart vigent2-backend
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 支付流程说明
|
||||
|
||||
```
|
||||
用户注册 → 登录(密码正确但 is_active=false)
|
||||
→ 后端返回 403 + payment_token
|
||||
→ 前端跳转 /pay 页面
|
||||
→ POST /api/payment/create-order → 返回支付宝收银台 URL
|
||||
→ 前端重定向到支付宝收银台页面(支持扫码、账号登录、余额等多种支付方式)
|
||||
→ 用户完成支付
|
||||
→ 支付宝异步回调 POST /api/payment/notify
|
||||
→ 后端验签 → 更新订单 → 激活用户(is_active=true, expires_at=+365天)
|
||||
→ 支付宝同步跳转回 /pay?out_trade_no=xxx
|
||||
→ 前端轮询 GET /api/payment/status/{out_trade_no}
|
||||
→ 轮询到 paid → 提示成功 → 跳转登录页
|
||||
→ 用户重新登录 → 成功进入系统
|
||||
```
|
||||
|
||||
**电脑网站支付 vs 当面付**:电脑网站支付(`alipay.trade.page.pay`)会跳转到支付宝官方收银台页面,用户可以选择扫码、支付宝账号登录、余额等多种方式支付,体验更好。当面付(`alipay.trade.precreate`)仅生成一个二维码,只能扫码支付。
|
||||
|
||||
会员到期续费同流程:登录时检测到过期 → 返回 PAYMENT_REQUIRED → 跳转 /pay。
|
||||
|
||||
管理员手动激活功能不受影响,两种方式并存。
|
||||
|
||||
---
|
||||
|
||||
## 涉及文件
|
||||
|
||||
| 文件 | 变更类型 | 说明 |
|
||||
|------|---------|------|
|
||||
| `backend/requirements.txt` | 修改 | 添加 `python-alipay-sdk` |
|
||||
| `backend/database/schema.sql` | 修改 | 新增 `orders` 表 |
|
||||
| `backend/app/core/config.py` | 修改 | 支付宝配置项 |
|
||||
| `backend/app/core/security.py` | 修改 | payment_token 函数 |
|
||||
| `backend/app/core/deps.py` | 修改 | is_active 安全兜底 |
|
||||
| `backend/app/repositories/orders.py` | 新建 | orders 数据层 |
|
||||
| `backend/app/modules/payment/__init__.py` | 新建 | 模块初始化 |
|
||||
| `backend/app/modules/payment/schemas.py` | 新建 | 请求/响应模型 |
|
||||
| `backend/app/modules/payment/service.py` | 新建 | 支付业务逻辑(电脑网站支付) |
|
||||
| `backend/app/modules/payment/router.py` | 新建 | 3 个 API 端点 |
|
||||
| `backend/app/modules/auth/router.py` | 修改 | 登录返回 PAYMENT_REQUIRED |
|
||||
| `backend/app/main.py` | 修改 | 注册 payment_router |
|
||||
| `backend/.env` | 修改 | 支付宝环境变量 |
|
||||
| `backend/keys/` | 新建 | PEM 密钥文件 |
|
||||
| `frontend/src/shared/lib/auth.ts` | 修改 | login() 处理 paymentToken |
|
||||
| `frontend/src/shared/api/axios.ts` | 修改 | PUBLIC_PATHS 加 /pay |
|
||||
| `frontend/src/app/login/page.tsx` | 修改 | paymentToken 跳转 |
|
||||
| `frontend/src/app/register/page.tsx` | 修改 | 注册成功提示文案 |
|
||||
| `frontend/src/app/pay/page.tsx` | 新建 | 付费页面(重定向到支付宝收银台) |
|
||||
|
||||
---
|
||||
|
||||
## 常见问题
|
||||
|
||||
### RSA key format is not supported
|
||||
|
||||
密钥文件缺少 PEM 头尾标记或未按 64 字符换行。参考「PEM 格式要求」重新格式化。
|
||||
|
||||
### ACQ.ACCESS_FORBIDDEN
|
||||
|
||||
应用未开通「电脑网站支付」产品。在支付宝开放平台 → 应用详情 → 产品管理中添加并开通。
|
||||
|
||||
### 支付宝回调不到
|
||||
|
||||
1. 检查 `ALIPAY_NOTIFY_URL` 是否公网 HTTPS 可达
|
||||
2. 检查 Nginx 是否将 `/api/payment/notify` 代理到后端
|
||||
3. 支付宝回调超时(15s 未响应)会重试,共重试 8 次,持续 24 小时
|
||||
|
||||
### 支付完成后页面未跳转回来
|
||||
|
||||
检查 `ALIPAY_RETURN_URL` 配置是否正确,必须是前端 `/pay` 页面的完整 URL(如 `https://vigent.hbyrkj.top/pay`)。支付宝会在用户支付完成后将浏览器重定向到此地址,并附带 `out_trade_no` 等参数。
|
||||
|
||||
### 前端显示"网络错误"而非具体错误
|
||||
|
||||
API 函数缺少 try/catch 捕获 axios 异常。已在 `auth.ts` 的 `register()` 和 `login()` 中修复。
|
||||
@@ -2,6 +2,12 @@
|
||||
|
||||
本文档定义后端开发的结构规范、接口契约与实现习惯。目标是让新功能按统一范式落地,旧逻辑在修复时逐步抽离。
|
||||
|
||||
## 文档定位
|
||||
|
||||
- 本文档只定义后端开发规范与工程约束(分层职责、契约、流程、代码习惯)。
|
||||
- 接口说明、部署运行与环境配置示例请查看 `Docs/BACKEND_README.md`。
|
||||
- 历史变更请记录在 `Docs/DevLogs/` 与 `Docs/TASK_COMPLETE.md`,不要写入本规范文档。
|
||||
|
||||
---
|
||||
|
||||
## 1. 模块化与分层原则
|
||||
@@ -39,10 +45,11 @@ backend/
|
||||
│ │ ├── generated_audios/ # 预生成配音管理(router/schemas/service)
|
||||
│ │ ├── login_helper/ # 扫码登录辅助
|
||||
│ │ ├── tools/ # 工具接口(router/schemas/service)
|
||||
│ │ ├── payment/ # 支付宝付费开通(router/schemas/service)
|
||||
│ │ └── admin/ # 管理员功能
|
||||
│ ├── repositories/ # Supabase 数据访问
|
||||
│ ├── services/ # 外部服务集成
|
||||
│ │ ├── uploader/ # 平台发布器(douyin/weixin)
|
||||
│ │ ├── uploader/ # 平台发布器(douyin/weixin/xiaohongshu/bilibili)
|
||||
│ │ ├── qr_login_service.py
|
||||
│ │ ├── publish_service.py
|
||||
│ │ ├── remotion_service.py
|
||||
@@ -79,10 +86,23 @@ 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 字),仅在视频画面中显示,不参与发布标题
|
||||
- `secondary_title_style_id` / `secondary_title_font_size` / `secondary_title_top_margin`: 副标题样式配置
|
||||
- workflow/remotion 侧需保持字段透传一致,避免前后端语义漂移。
|
||||
|
||||
### `/api/videos/cleanup` 行为约定
|
||||
|
||||
- 仅清理当前用户在 Storage 中的生成产物:
|
||||
- `outputs` bucket(生成视频)
|
||||
- `generated-audios` bucket(预生成配音 `.wav/.json`)
|
||||
- 清理接口采用严格成功语义:
|
||||
- 全部删除成功才返回 success
|
||||
- 任一删除失败返回错误,前端应保留清理弹窗并允许重试
|
||||
- 下载接口约定:`GET /api/videos/generated/{video_id}/download` 必须返回 `Content-Disposition: attachment`,用于前端一键下载,避免浏览器改为在线播放。
|
||||
|
||||
---
|
||||
|
||||
## 4. 认证与权限
|
||||
@@ -105,6 +125,8 @@ backend/
|
||||
|
||||
- 所有文件上传/下载/删除/移动通过 `services/storage.py`。
|
||||
- 需要重命名时使用 `move_file`,避免直接读写 Storage。
|
||||
- `delete_file` 必须向上抛出异常,不允许静默吞错(避免清理接口出现“假成功”)。
|
||||
- `list_files` 默认容错返回空列表;清理等强一致场景应使用 `strict=True`。
|
||||
|
||||
### Cookie 存储(用户隔离)
|
||||
|
||||
@@ -152,6 +174,14 @@ backend/user_data/{user_uuid}/cookies/
|
||||
- `LATENTSYNC_*`
|
||||
- `CORS_ORIGINS` (CORS 白名单,默认 *)
|
||||
|
||||
### MuseTalk / 混合唇形同步
|
||||
- `MUSETALK_GPU_ID` (GPU 编号,默认 0)
|
||||
- `MUSETALK_API_URL` (常驻服务地址,默认 http://localhost:8011)
|
||||
- `MUSETALK_BATCH_SIZE` (推理批大小,默认 32)
|
||||
- `MUSETALK_VERSION` (v15)
|
||||
- `MUSETALK_USE_FLOAT16` (半精度,默认 true)
|
||||
- `LIPSYNC_DURATION_THRESHOLD` (秒,>=此值用 MuseTalk;代码默认 120,本仓库当前 `.env` 配置 100)
|
||||
|
||||
### 微信视频号
|
||||
- `WEIXIN_HEADLESS_MODE` (headful/headless-new)
|
||||
- `WEIXIN_CHROME_PATH` / `WEIXIN_BROWSER_CHANNEL`
|
||||
@@ -166,15 +196,30 @@ backend/user_data/{user_uuid}/cookies/
|
||||
- `DOUYIN_LOCALE` / `DOUYIN_TIMEZONE_ID`
|
||||
- `DOUYIN_FORCE_SWIFTSHADER`
|
||||
- `DOUYIN_DEBUG_ARTIFACTS` / `DOUYIN_RECORD_VIDEO` / `DOUYIN_KEEP_SUCCESS_VIDEO`
|
||||
- `DOUYIN_COOKIE` (抖音视频下载 Cookie)
|
||||
|
||||
### 小红书
|
||||
- `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`
|
||||
- `ALIPAY_SANDBOX` (沙箱模式,默认 false)
|
||||
- `PAYMENT_AMOUNT` (会员价格,默认 999.00)
|
||||
- `PAYMENT_EXPIRE_DAYS` (会员有效天数,默认 365)
|
||||
|
||||
---
|
||||
|
||||
## 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/
|
||||
@@ -25,6 +31,7 @@ backend/
|
||||
│ │ ├── generated_audios/ # 预生成配音管理(router/schemas/service)
|
||||
│ │ ├── login_helper/ # 扫码登录辅助
|
||||
│ │ ├── tools/ # 工具接口(router/schemas/service)
|
||||
│ │ ├── payment/ # 支付宝付费开通(router/schemas/service)
|
||||
│ │ └── admin/ # 管理员功能
|
||||
│ ├── repositories/ # Supabase 数据访问
|
||||
│ ├── services/ # 外部服务集成 (TTS/Remotion/Storage/Uploader 等)
|
||||
@@ -35,6 +42,8 @@ backend/
|
||||
└── requirements.txt # 依赖清单
|
||||
```
|
||||
|
||||
> 详细分层职责(router/service/workflow/repositories)与开发约束请查看 `Docs/BACKEND_DEV.md`。
|
||||
|
||||
---
|
||||
|
||||
## 🔌 API 接口规范
|
||||
@@ -55,23 +64,32 @@ backend/
|
||||
|
||||
2. **视频生成 (Videos)**
|
||||
* `POST /api/videos/generate`: 提交生成任务
|
||||
* `GET/POST /api/videos/voice-preview`: 生成音色试听短音频(返回二进制音频流)
|
||||
* `POST /api/videos/cleanup`: 清理当前用户工作区生成产物(outputs + generated-audios)
|
||||
* `GET /api/videos/tasks/{task_id}`: 查询单个任务状态
|
||||
* `GET /api/videos/tasks`: 获取用户所有任务列表
|
||||
* `GET /api/videos/generated`: 获取历史视频列表
|
||||
* `GET /api/videos/generated/{video_id}/download`: 下载历史视频(`Content-Disposition: attachment`)
|
||||
* `DELETE /api/videos/generated/{video_id}`: 删除历史视频
|
||||
|
||||
> `POST /api/videos/cleanup` 采用严格成功语义:仅当目标文件删除全部成功时返回 success;存在删除失败会返回错误并提示重试。
|
||||
|
||||
3. **素材管理 (Materials)**
|
||||
* `POST /api/materials`: 上传素材
|
||||
* `GET /api/materials`: 获取素材列表
|
||||
* `PUT /api/materials/{material_id}`: 重命名素材
|
||||
* `GET /api/materials/stream/{material_id}`: 同源流式返回素材文件(用于前端 canvas 截帧,避免跨域 CORS taint)
|
||||
|
||||
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`: 字幕样式列表
|
||||
@@ -100,9 +118,16 @@ backend/
|
||||
* `POST /api/tools/extract-script`: 从视频链接提取文案
|
||||
|
||||
10. **健康检查**
|
||||
* `GET /api/lipsync/health`: LatentSync 服务健康状态
|
||||
* `GET /api/lipsync/health`: 唇形同步服务健康状态(含 LatentSync + MuseTalk + 混合路由阈值)
|
||||
* `GET /api/voiceclone/health`: CosyVoice 3.0 服务健康状态
|
||||
|
||||
11. **支付 (Payment)**
|
||||
* `POST /api/payment/create-order`: 创建支付宝电脑网站支付订单(需 payment_token)
|
||||
* `POST /api/payment/notify`: 支付宝异步通知回调(返回纯文本 success/fail)
|
||||
* `GET /api/payment/status/{out_trade_no}`: 查询订单支付状态(前端轮询)
|
||||
|
||||
> 登录时若账号未激活或已过期,返回 403 + `payment_token`,前端跳转 `/pay` 页面完成付费。详见 [支付宝部署指南](ALIPAY_DEPLOY.md)。
|
||||
|
||||
### 统一响应结构
|
||||
|
||||
```json
|
||||
@@ -129,15 +154,23 @@ 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
|
||||
- `subtitle_font_size`: 字幕字号(覆盖样式默认值)
|
||||
- `title_font_size`: 标题字号(覆盖样式默认值)
|
||||
- `title_top_margin`: 标题距顶部像素
|
||||
- `secondary_title`: 片头副标题文字(可选,限 20 字,仅视频画面显示)
|
||||
- `secondary_title_style_id`: 副标题样式 ID
|
||||
- `secondary_title_font_size`: 副标题字号
|
||||
- `secondary_title_top_margin`: 副标题距主标题间距
|
||||
- `subtitle_bottom_margin`: 字幕距底部像素
|
||||
- `enable_subtitles`: 是否启用字幕
|
||||
- `bgm_id`: 背景音乐 ID
|
||||
@@ -148,6 +181,18 @@ backend/
|
||||
- 多素材片段在拼接前统一重编码,并强制 `25fps + CFR`,减少段边界时间基不一致导致的画面卡顿。
|
||||
- concat 流程启用 `+genpts` 重建时间戳,提升拼接后时间轴连续性。
|
||||
- 对带旋转元数据的 MOV 素材会先做方向归一化,再进入分辨率判断和后续流程。
|
||||
- compose 阶段(视频轨+音频轨合并)在**无需循环视频**时使用 `-c:v copy` 流复制;需要循环时才重编码。
|
||||
- FFmpeg 子进程设有超时保护:`_run_ffmpeg()` 600 秒、`_get_duration()` 30 秒,防止畸形文件导致永久挂起。
|
||||
|
||||
### 全局并发控制
|
||||
|
||||
- 视频生成入口使用 `asyncio.Semaphore(2)` 限制最多 2 个任务同时执行,排队中的任务显示"排队中..."状态。
|
||||
- Redis 任务 key 设有 TTL:创建时 24 小时,completed/failed 状态 2 小时,`list()` 时自动清理过期索引。
|
||||
|
||||
### 字幕时间戳优化
|
||||
|
||||
- Whisper 输出经 `smooth_word_timestamps()` 三步平滑:单调递增保证、重叠消除(中点分割)、微小间隙填补(<50ms)。
|
||||
- 支持 `original_text` 原文节奏映射:原文字符按比例映射到 Whisper 时间戳上,解决 AI 改写/多语言文案与转录不一致问题。
|
||||
|
||||
## 📦 资源库与静态资源
|
||||
|
||||
@@ -178,7 +223,7 @@ pip install -r requirements.txt
|
||||
|
||||
### 3. 环境变量配置
|
||||
|
||||
复制 `.env.example` 到 `.env` 并配置必要的 Key:
|
||||
当前仓库使用 `backend/.env` 作为运行配置基准;请按你的环境替换敏感值并核对以下关键项(生产环境请勿提交真实密钥):
|
||||
|
||||
```ini
|
||||
# Supabase
|
||||
@@ -190,6 +235,18 @@ GLM_API_KEY=your_glm_api_key
|
||||
|
||||
# LatentSync 配置
|
||||
LATENTSYNC_GPU_ID=1
|
||||
|
||||
# MuseTalk 配置 (长视频唇形同步)
|
||||
MUSETALK_GPU_ID=0
|
||||
MUSETALK_API_URL=http://localhost:8011
|
||||
MUSETALK_BATCH_SIZE=32
|
||||
LIPSYNC_DURATION_THRESHOLD=100
|
||||
|
||||
# MuseTalk 可调参数(示例)
|
||||
MUSETALK_DETECT_EVERY=2
|
||||
MUSETALK_BLEND_CACHE_EVERY=2
|
||||
MUSETALK_ENCODE_CRF=14
|
||||
MUSETALK_ENCODE_PRESET=slow
|
||||
```
|
||||
|
||||
### 4. 启动服务
|
||||
@@ -201,43 +258,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` 注册路由。
|
||||
|
||||
### 添加定时任务
|
||||
|
||||
目前推荐使用 **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
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
| 模型 | Fun-CosyVoice3-0.5B-2512 (0.5B 参数) |
|
||||
| 端口 | 8010 |
|
||||
| GPU | 0 (CUDA_VISIBLE_DEVICES=0) |
|
||||
| PM2 名称 | vigent2-cosyvoice (id=15) |
|
||||
| 推理精度 | FP16 (自动混合精度) |
|
||||
| PM2 名称 | vigent2-cosyvoice |
|
||||
| Conda 环境 | cosyvoice (Python 3.10) |
|
||||
| 启动脚本 | `run_cosyvoice.sh` |
|
||||
| 服务脚本 | `models/CosyVoice/cosyvoice_server.py` |
|
||||
@@ -69,6 +70,18 @@ run_cosyvoice.sh # PM2 启动脚本
|
||||
| ref_text | string | 是 | 参考音频的转写文字 |
|
||||
| language | string | 否 | 语言 (默认 "Chinese",CosyVoice 自动检测) |
|
||||
| speed | float | 否 | 语速 (默认 1.0,范围 0.5-2.0,建议 0.8-1.2) |
|
||||
| instruct_text | string | 否 | 语气指令 (默认 "",非空时切换为 `inference_instruct2` 模式) |
|
||||
|
||||
**推理模式分支:**
|
||||
- `instruct_text` 为空 → `inference_zero_shot(text, prompt_text, ref_audio)` — 纯声音克隆
|
||||
- `instruct_text` 非空 → `inference_instruct2(text, instruct_text, ref_audio)` — 带语气/情绪控制的声音克隆
|
||||
|
||||
**支持的语气指令示例:**
|
||||
```
|
||||
"You are a helpful assistant. 请非常开心地说一句话。<|endofprompt|>"
|
||||
"You are a helpful assistant. 请非常伤心地说一句话。<|endofprompt|>"
|
||||
"You are a helpful assistant. 请非常生气地说一句话。<|endofprompt|>"
|
||||
```
|
||||
|
||||
**返回:** WAV 音频文件
|
||||
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
| 服务器 | Dell PowerEdge R730 |
|
||||
| CPU | 2× Intel Xeon E5-2680 v4 (56 线程) |
|
||||
| 内存 | 192GB DDR4 |
|
||||
| GPU 0 | NVIDIA RTX 3090 24GB |
|
||||
| GPU 1 | NVIDIA RTX 3090 24GB (用于 LatentSync) |
|
||||
| GPU 0 | NVIDIA RTX 3090 24GB (MuseTalk + CosyVoice) |
|
||||
| GPU 1 | NVIDIA RTX 3090 24GB (LatentSync) |
|
||||
| 部署路径 | `/home/rongye/ProgramFiles/ViGent2` |
|
||||
|
||||
---
|
||||
@@ -72,7 +72,9 @@ cd /home/rongye/ProgramFiles/ViGent2
|
||||
|
||||
---
|
||||
|
||||
## 步骤 3: 部署 AI 模型 (LatentSync 1.6)
|
||||
## 步骤 3: 部署 AI 模型
|
||||
|
||||
### 3a. LatentSync 1.6 (短视频唇形同步, GPU1)
|
||||
|
||||
> ⚠️ **重要**:LatentSync 需要独立的 Conda 环境和 **~18GB VRAM**。请**不要**直接安装在后端环境中。
|
||||
|
||||
@@ -93,6 +95,26 @@ conda activate latentsync
|
||||
python -m scripts.server # 测试能否启动,Ctrl+C 退出
|
||||
```
|
||||
|
||||
### 3b. MuseTalk 1.5 (长视频唇形同步, GPU0)
|
||||
|
||||
> MuseTalk 是单步潜空间修复模型(非扩散模型),推理速度接近实时,适合达到路由阈值的长视频(本仓库当前 `.env` 示例为 >=100s)。与 CosyVoice 共享 GPU0,fp16 推理约需 4-8GB 显存。合成阶段已改为 FFmpeg rawvideo 管道直编码(`libx264` + 可配 CRF/preset)并保留 numpy blending,减少中间有损文件。
|
||||
|
||||
请参考详细的独立部署指南:
|
||||
**[MuseTalk 部署指南](MUSETALK_DEPLOY.md)**
|
||||
|
||||
简要步骤:
|
||||
1. 创建独立的 `musetalk` Conda 环境 (Python 3.10 + PyTorch 2.0.1 + CUDA 11.8)
|
||||
2. 安装 mmcv/mmdet/mmpose 等依赖
|
||||
3. 下载模型权重 (`download_weights.sh`)
|
||||
4. 创建必要的软链接 (`musetalk/config.json`, `musetalk/musetalkV15`)
|
||||
|
||||
**验证 MuseTalk 部署**:
|
||||
```bash
|
||||
cd /home/rongye/ProgramFiles/ViGent2/models/MuseTalk
|
||||
/home/rongye/ProgramFiles/miniconda3/envs/musetalk/bin/python scripts/server.py
|
||||
# 另一个终端: curl http://localhost:8011/health
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 步骤 4: 安装后端依赖
|
||||
@@ -114,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
|
||||
- **微信视频号发布**:标题、描述、标签统一写入"视频描述"字段
|
||||
|
||||
---
|
||||
|
||||
@@ -173,24 +199,23 @@ 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 | 推理步数 (20-50) |
|
||||
| `LATENTSYNC_GUIDANCE_SCALE` | 1.5 | 引导系数 (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 |
|
||||
| `REDIS_URL` | `redis://localhost:6379/0` | 任务状态存储(不可用时回退内存) |
|
||||
| `WEIXIN_HEADLESS_MODE` | headless-new | 视频号 Playwright 模式 (headful/headless-new) |
|
||||
@@ -205,14 +230,35 @@ 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 允许源 (生产环境建议白名单) |
|
||||
| `DOUYIN_COOKIE` | 空 | 抖音视频下载 Cookie (文案提取功能) |
|
||||
| `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` | 100 | 秒,>=此值用 MuseTalk,<此值用 LatentSync(代码默认 120,建议在 `.env` 显式配置) |
|
||||
| `ALIPAY_APP_ID` | 空 | 支付宝应用 APPID |
|
||||
| `ALIPAY_PRIVATE_KEY_PATH` | 空 | 应用私钥 PEM 文件路径 |
|
||||
| `ALIPAY_PUBLIC_KEY_PATH` | 空 | 支付宝公钥 PEM 文件路径 |
|
||||
| `ALIPAY_NOTIFY_URL` | 空 | 支付宝异步回调地址 (公网 HTTPS) |
|
||||
| `ALIPAY_RETURN_URL` | 空 | 支付完成后浏览器跳转地址 |
|
||||
| `PAYMENT_AMOUNT` | `999.00` | 会员价格 (元) |
|
||||
| `PAYMENT_EXPIRE_DAYS` | `365` | 会员有效天数 |
|
||||
|
||||
> 支付宝完整配置步骤(密钥生成、PEM 格式、产品开通等)请参考 **[支付宝部署指南](ALIPAY_DEPLOY.md)**。
|
||||
|
||||
---
|
||||
|
||||
@@ -262,6 +308,13 @@ cd /home/rongye/ProgramFiles/ViGent2/models/LatentSync
|
||||
conda activate latentsync
|
||||
python -m scripts.server
|
||||
```
|
||||
|
||||
### 启动 MuseTalk (终端 4, 长视频唇形同步)
|
||||
|
||||
```bash
|
||||
cd /home/rongye/ProgramFiles/ViGent2/models/MuseTalk
|
||||
/home/rongye/ProgramFiles/miniconda3/envs/musetalk/bin/python scripts/server.py
|
||||
```
|
||||
|
||||
### 验证
|
||||
|
||||
@@ -355,7 +408,27 @@ pm2 save
|
||||
curl http://localhost:8010/health
|
||||
```
|
||||
|
||||
### 5. 启动服务看门狗 (Watchdog)
|
||||
### 5. 启动 MuseTalk 长视频唇形同步服务
|
||||
|
||||
> 达到阈值(当前 `.env` 示例为 >=100s)自动路由到 MuseTalk。MuseTalk 不可用时自动回退 LatentSync。
|
||||
> 详细部署步骤见 [MuseTalk 部署指南](MUSETALK_DEPLOY.md)。
|
||||
|
||||
1. 启动脚本位于项目根目录: `run_musetalk.sh`
|
||||
|
||||
2. 使用 pm2 启动:
|
||||
```bash
|
||||
cd /home/rongye/ProgramFiles/ViGent2
|
||||
pm2 start ./run_musetalk.sh --name vigent2-musetalk
|
||||
pm2 save
|
||||
```
|
||||
|
||||
3. 验证服务:
|
||||
```bash
|
||||
curl http://localhost:8011/health
|
||||
# {"status":"ok","model_loaded":true}
|
||||
```
|
||||
|
||||
### 6. 启动服务看门狗 (Watchdog)
|
||||
|
||||
> 🛡️ **推荐**:监控 CosyVoice 和 LatentSync 服务健康状态,卡死时自动重启。
|
||||
|
||||
@@ -372,6 +445,8 @@ pm2 save
|
||||
pm2 startup
|
||||
```
|
||||
|
||||
> **提示**: 完整的 PM2 进程列表应包含 5-6 个服务: vigent2-backend, vigent2-frontend, vigent2-latentsync, vigent2-cosyvoice, vigent2-musetalk, vigent2-watchdog。
|
||||
|
||||
### pm2 常用命令
|
||||
|
||||
```bash
|
||||
@@ -379,6 +454,7 @@ pm2 status # 查看所有服务状态
|
||||
pm2 logs # 查看所有日志
|
||||
pm2 logs vigent2-backend # 查看后端日志
|
||||
pm2 logs vigent2-cosyvoice # 查看 CosyVoice 日志
|
||||
pm2 logs vigent2-musetalk # 查看 MuseTalk 日志
|
||||
pm2 restart all # 重启所有服务
|
||||
pm2 stop vigent2-latentsync # 停止 LatentSync 服务
|
||||
pm2 delete all # 删除所有服务
|
||||
@@ -518,6 +594,7 @@ sudo lsof -i :8006
|
||||
sudo lsof -i :3002
|
||||
sudo lsof -i :8007
|
||||
sudo lsof -i :8010 # CosyVoice
|
||||
sudo lsof -i :8011 # MuseTalk
|
||||
```
|
||||
|
||||
### 查看日志
|
||||
@@ -528,6 +605,7 @@ pm2 logs vigent2-backend
|
||||
pm2 logs vigent2-frontend
|
||||
pm2 logs vigent2-latentsync
|
||||
pm2 logs vigent2-cosyvoice
|
||||
pm2 logs vigent2-musetalk
|
||||
```
|
||||
|
||||
### SSH 连接卡顿 / 系统响应慢
|
||||
@@ -558,6 +636,7 @@ pm2 logs vigent2-cosyvoice
|
||||
| `playwright` | 社交媒体自动发布 |
|
||||
| `biliup` | B站视频上传 |
|
||||
| `loguru` | 日志管理 |
|
||||
| `python-alipay-sdk` | 支付宝支付集成 |
|
||||
|
||||
### 前端关键依赖
|
||||
|
||||
|
||||
254
Docs/DevLogs/Day25.md
Normal file
254
Docs/DevLogs/Day25.md
Normal file
@@ -0,0 +1,254 @@
|
||||
## 🔧 文案提取助手修复 — 抖音链接无法提取文案 (Day 25)
|
||||
|
||||
### 概述
|
||||
|
||||
文案提取助手粘贴抖音链接后无法提取文案,yt-dlp 报错 `Fresh cookies are needed`,手动回退方案也因抖音页面结构变化失效。本日完成了完整修复,并清理了不再需要的 `DOUYIN_COOKIE` 配置。
|
||||
|
||||
---
|
||||
|
||||
## 🐛 问题诊断
|
||||
|
||||
### 错误链路
|
||||
|
||||
1. **yt-dlp 失败**:`ERROR: [Douyin] Fresh cookies (not necessarily logged in) are needed`
|
||||
- yt-dlp 版本 `2025.12.08` 过旧
|
||||
- 抖音 API `aweme/v1/web/aweme/detail/` 需要签名 cookie(`s_v_web_id` 等),即使升级 yt-dlp 到最新版 + 传入 cookie 仍无法解决,属 yt-dlp 已知问题
|
||||
2. **手动回退失败**:`Could not find RENDER_DATA in page`
|
||||
- 旧方案通过桌面端用户主页 + `modal_id` 访问,抖音 SSR 已不再返回 `videoDetail` 数据
|
||||
3. **`.env` 中 `DOUYIN_COOKIE`**:时间戳 2024 年 12 月,早已过期
|
||||
|
||||
---
|
||||
|
||||
## ✅ 修复方案:移动端分享页 + 自动获取 ttwid
|
||||
|
||||
### 核心思路
|
||||
|
||||
放弃依赖 yt-dlp 下载抖音视频和手动维护 cookie,改为:
|
||||
|
||||
1. 自动从 ByteDance 公共 API 获取新鲜 `ttwid`(匿名令牌,不绑定账号)
|
||||
2. 用 `ttwid` 访问移动端分享页 `m.douyin.com/share/video/{id}`
|
||||
3. 从页面内嵌 JSON 中提取 `play_addr` 播放地址并下载
|
||||
|
||||
### 关键代码(`_download_douyin_manual` 重写)
|
||||
|
||||
```python
|
||||
# 1. 获取新鲜 ttwid
|
||||
ttwid_resp = await client.post(
|
||||
"https://ttwid.bytedance.com/ttwid/union/register/",
|
||||
json={"region": "cn", "aid": 6383, "service": "www.douyin.com", ...}
|
||||
)
|
||||
ttwid = ttwid_resp.cookies.get("ttwid", "")
|
||||
|
||||
# 2. 访问移动端分享页
|
||||
page_resp = await client.get(
|
||||
f"https://m.douyin.com/share/video/{video_id}",
|
||||
headers={"cookie": f"ttwid={ttwid}", ...}
|
||||
)
|
||||
|
||||
# 3. 提取 play_addr
|
||||
addr_match = re.search(r'"play_addr":\{"uri":"([^"]+)","url_list":\["([^"]+)"', page_text)
|
||||
video_url = addr_match.group(2).replace(r"\u002F", "/")
|
||||
```
|
||||
|
||||
### 优势
|
||||
|
||||
- 不再依赖手动维护的 `DOUYIN_COOKIE`,ttwid 每次请求自动获取
|
||||
- 不受 yt-dlp 对抖音支持状况影响
|
||||
- 所有用户通用,不绑定特定账号
|
||||
|
||||
---
|
||||
|
||||
## 🧹 清理 DOUYIN_COOKIE 配置
|
||||
|
||||
`DOUYIN_COOKIE` 仅用于文案提取,新方案不再需要,已从以下位置删除:
|
||||
|
||||
| 文件 | 变更 |
|
||||
|------|------|
|
||||
| `backend/.env` | 删除 `DOUYIN_COOKIE` 配置项及注释 |
|
||||
| `backend/app/core/config.py` | 删除 `DOUYIN_COOKIE: str = ""` 字段定义 |
|
||||
| `backend/app/modules/tools/service.py` | 删除 yt-dlp 传 cookie 逻辑和 `_write_netscape_cookies` 辅助函数 |
|
||||
|
||||
---
|
||||
|
||||
## 🔤 前端文案修正
|
||||
|
||||
将文案提取界面中的"AI 洗稿结果"改为"AI 改写结果"。
|
||||
|
||||
| 文件 | 变更 |
|
||||
|------|------|
|
||||
| `frontend/src/features/home/ui/ScriptExtractionModal.tsx` | `AI 洗稿结果` → `AI 改写结果` |
|
||||
| `backend/app/modules/tools/service.py` | 注释中"洗稿"→"改写" |
|
||||
| `backend/app/services/glm_service.py` | docstring 中"洗稿"→"改写文案" |
|
||||
|
||||
---
|
||||
|
||||
## 📦 其他变更
|
||||
|
||||
- **yt-dlp 升级**:`2025.12.08` → `2026.2.21`
|
||||
- **yt-dlp 初始化修正**:改为 `YoutubeDL(ydl_opts)` 直接传参初始化(原先空初始化后 update params 不生效)
|
||||
- **User-Agent 更新**:yt-dlp 中 `Chrome/91` → `Chrome/131`
|
||||
|
||||
---
|
||||
|
||||
## 涉及文件汇总
|
||||
|
||||
### 后端修改
|
||||
|
||||
| 文件 | 变更 |
|
||||
|------|------|
|
||||
| `backend/app/modules/tools/service.py` | 重写 `_download_douyin_manual`(移动端分享页方案);修正 yt-dlp 初始化;清理 cookie 相关代码;注释改写 |
|
||||
| `backend/app/services/glm_service.py` | docstring "洗稿" → "改写文案" |
|
||||
| `backend/app/core/config.py` | 删除 `DOUYIN_COOKIE` 字段 |
|
||||
| `backend/.env` | 删除 `DOUYIN_COOKIE` 配置 |
|
||||
| `backend/requirements.txt` | yt-dlp 版本升级 |
|
||||
|
||||
### 前端修改
|
||||
|
||||
| 文件 | 变更 |
|
||||
|------|------|
|
||||
| `frontend/src/features/home/ui/ScriptExtractionModal.tsx` | "AI 洗稿结果" → "AI 改写结果" |
|
||||
|
||||
---
|
||||
|
||||
## ✏️ AI 智能改写 — 自定义提示词功能
|
||||
|
||||
### 概述
|
||||
|
||||
文案提取助手的"AI 智能改写"原先使用硬编码 prompt,用户无法定制改写风格。本次在 checkbox 右侧新增"自定义提示词"折叠区域,用户可编辑自定义 prompt,持久化到 localStorage,后端按需替换默认 prompt。
|
||||
|
||||
### 后端修改
|
||||
|
||||
**路由层** (`router.py`):`extract_script_tool` 新增可选 Form 参数 `custom_prompt: Optional[str] = Form(None)`,透传给 service。
|
||||
|
||||
**服务层** (`service.py`):`extract_script()` 签名新增 `custom_prompt`,透传给 `glm_service.rewrite_script(script, custom_prompt)`。
|
||||
|
||||
**AI 层** (`glm_service.py`):`rewrite_script(self, text, custom_prompt=None)`,若 `custom_prompt` 有值则用自定义 prompt + 原文拼接,否则保持原有默认 prompt。
|
||||
|
||||
```python
|
||||
if custom_prompt and custom_prompt.strip():
|
||||
prompt = f"""{custom_prompt.strip()}
|
||||
|
||||
原始文案:
|
||||
{text}"""
|
||||
else:
|
||||
prompt = f"""请将以下视频文案进行改写。...(原有默认)"""
|
||||
```
|
||||
|
||||
### 前端修改
|
||||
|
||||
**Hook** (`useScriptExtraction.ts`):
|
||||
- 新增 `customPrompt` / `showCustomPrompt` 状态
|
||||
- 初始值从 `localStorage.getItem("vigent_rewriteCustomPrompt")` 恢复
|
||||
- `customPrompt` 变化时防抖 300ms 保存到 localStorage
|
||||
- `handleExtract()` 中若 `doRewrite && customPrompt.trim()` 有值,追加 `formData.append("custom_prompt", ...)`
|
||||
- modal 重置时不清空 customPrompt(持久化偏好)
|
||||
|
||||
**UI** (`ScriptExtractionModal.tsx`):
|
||||
- checkbox 同行右侧新增"自定义提示词 ▼"按钮(仅 `doRewrite` 时显示)
|
||||
- 点击展开 textarea 编辑区域,底部提示"留空则使用默认提示词"
|
||||
- 取消勾选 AI 智能改写时,自定义提示词区域自动隐藏
|
||||
|
||||
### 涉及文件
|
||||
|
||||
| 文件 | 变更 |
|
||||
|------|------|
|
||||
| `backend/app/modules/tools/router.py` | 新增 `custom_prompt` Form 参数 |
|
||||
| `backend/app/modules/tools/service.py` | `extract_script()` 透传 `custom_prompt` |
|
||||
| `backend/app/services/glm_service.py` | `rewrite_script()` 支持自定义 prompt |
|
||||
| `frontend/.../useScriptExtraction.ts` | 新增状态、localStorage 持久化、FormData 传参 |
|
||||
| `frontend/.../ScriptExtractionModal.tsx` | UI 按钮 + 展开 textarea |
|
||||
|
||||
### 验证
|
||||
|
||||
- 后端 `python -m py_compile` 三个文件通过
|
||||
- 前端 `npx tsc --noEmit` 通过
|
||||
|
||||
---
|
||||
|
||||
## 🐛 SSR 构建修复 — localStorage is not defined
|
||||
|
||||
### 问题
|
||||
|
||||
`npm run build` 报错 `ReferenceError: localStorage is not defined`,因为 `useScriptExtraction.ts` 中 `useState` 的初始化函数在 SSR(Node.js)环境下也会执行,而服务端没有 `localStorage`。
|
||||
|
||||
### 修复
|
||||
|
||||
`useState` 初始化加 `typeof window !== "undefined"` 守卫:
|
||||
|
||||
```typescript
|
||||
const [customPrompt, setCustomPrompt] = useState(
|
||||
() => typeof window !== "undefined" ? localStorage.getItem(CUSTOM_PROMPT_KEY) || "" : ""
|
||||
);
|
||||
```
|
||||
|
||||
| 文件 | 变更 |
|
||||
|------|------|
|
||||
| `frontend/.../useScriptExtraction.ts` | `useState` 初始化增加 SSR 安全守卫 |
|
||||
|
||||
---
|
||||
|
||||
## 🎬 片头副标题功能
|
||||
|
||||
### 概述
|
||||
|
||||
新增片头副标题(secondary_title),显示在主标题下方,用于补充说明或悬念引导。副标题有独立的样式配置(字体、字号、颜色等),可由 AI 同时生成,20 字限制,仅在视频画面中显示,不参与发布标题。
|
||||
|
||||
命名约定:后端 `secondary_title`(snake_case),前端 `videoSecondaryTitle`(camelCase),用户界面"片头副标题"。
|
||||
|
||||
---
|
||||
|
||||
### 后端修改
|
||||
|
||||
| 文件 | 变更 |
|
||||
|------|------|
|
||||
| `backend/app/modules/videos/schemas.py` | `GenerateRequest` 新增 4 个可选字段:`secondary_title`、`secondary_title_style_id`、`secondary_title_font_size`、`secondary_title_top_margin` |
|
||||
| `backend/app/services/glm_service.py` | AI prompt 增加副标题生成要求(不超过20字),JSON 格式新增 `secondary_title` 字段 |
|
||||
| `backend/app/modules/ai/router.py` | `GenerateMetaResponse` 增加 `secondary_title: str = ""`,endpoint 返回时取 `result.get("secondary_title", "")` |
|
||||
| `backend/app/modules/videos/workflow.py` | `use_remotion` 条件增加 `or req.secondary_title`;副标题样式解析复用 `get_style("title", ...)`;字号/间距覆盖;`prepare_style_for_remotion` 处理副标题字体;`remotion_service.render()` 传入 `secondary_title` + `secondary_title_style` |
|
||||
| `backend/app/services/remotion_service.py` | `render()` 新增 `secondary_title` 和 `secondary_title_style` 参数,构建 CLI 参数 `--secondaryTitle` 和 `--secondaryTitleStyle` |
|
||||
|
||||
### Remotion 修改
|
||||
|
||||
| 文件 | 变更 |
|
||||
|------|------|
|
||||
| `remotion/render.ts` | `RenderOptions` 新增 `secondaryTitle?` + `secondaryTitleStyle?`;`parseArgs()` 新增 switch case;`inputProps` 新增两个字段 |
|
||||
| `remotion/src/components/Title.tsx` | `TitleProps` 新增 `secondaryTitle?` 和 `secondaryTitleStyle?`;`AbsoluteFill` 改为 `flexDirection: 'column'` 垂直堆叠;主标题 `<h1>` 后增加副标题 `<h2>`,独立样式(默认字号 48px、字重 700),共享淡入淡出动画;副标题字体使用独立 `@font-face`(`SecondaryTitleFont`)避免与主标题冲突 |
|
||||
| `remotion/src/Video.tsx` | `VideoProps` 新增 `secondaryTitle?` + `secondaryTitleStyle?`;传递给 `<Title>` 组件;渲染条件改为 `{(title \|\| secondaryTitle) && ...}` |
|
||||
| `remotion/src/Root.tsx` | `defaultProps` 新增 `secondaryTitle: undefined` + `secondaryTitleStyle: undefined` |
|
||||
|
||||
### 前端修改
|
||||
|
||||
| 文件 | 变更 |
|
||||
|------|------|
|
||||
| `frontend/src/shared/lib/title.ts` | 新增 `SECONDARY_TITLE_MAX_LENGTH = 20` 和 `clampSecondaryTitle()` |
|
||||
| `frontend/src/features/home/model/useHomeController.ts` | 新增状态 `videoSecondaryTitle`、`selectedSecondaryTitleStyleId`、`secondaryTitleFontSize`、`secondaryTitleTopMargin`、`secondaryTitleSizeLocked`;新建 `secondaryTitleInput = useTitleInput({ maxLength: 20 })`(不 sync 到发布页);`handleGenerateMeta()` 接收并填充 `secondary_title`;`handleGenerate()` 构建 payload 增加副标题字段;return 暴露所有新状态 |
|
||||
| `frontend/src/features/home/model/useHomePersistence.ts` | 新增 localStorage key:`secondaryTitle`、`secondaryTitleStyle`、`secondaryTitleFontSize`、`secondaryTitleTopMargin`;对应的恢复和保存 effect |
|
||||
| `frontend/src/features/home/ui/TitleSubtitlePanel.tsx` | Props 新增副标题相关;主标题输入框下方添加"片头副标题(限制20个字)"输入框;副标题样式选择器(复用 titleStyles 预设)、字号滑块(30-100px)、间距滑块(0-100px) |
|
||||
| `frontend/src/features/home/ui/FloatingStylePreview.tsx` | 标题预览改为 flex column 布局;主标题下方增加副标题预览行,独立样式渲染 |
|
||||
| `frontend/src/features/home/ui/HomePage.tsx` | 从 `useHomeController` 解构新状态,传给 `TitleSubtitlePanel` |
|
||||
|
||||
---
|
||||
|
||||
## 🐛 参考音频上传 — 中文文件名 InvalidKey 修复
|
||||
|
||||
### 问题
|
||||
|
||||
上传中文名参考音频(如"我的声音.wav")时,Supabase Storage 报 `InvalidKey`,因为存储路径直接使用了原始中文文件名。
|
||||
|
||||
### 修复
|
||||
|
||||
在 `ref_audios/service.py` 新增 `sanitize_filename()` 函数,将存储路径的文件名清洗为 ASCII 安全字符(仅 `A-Za-z0-9._-`):
|
||||
|
||||
- NFKD 规范化 → 丢弃非 ASCII → 非法字符替换为 `_`
|
||||
- 纯中文/emoji 清洗后为空时,使用 MD5 哈希兜底(如 `audio_e924b1193007`)
|
||||
- 文件名限长 50 字符
|
||||
- 原始中文文件名保留在 metadata 中作为展示名,前端显示不受影响
|
||||
|
||||
```
|
||||
修复前: cbbe.../1771915755_我的声音.wav → InvalidKey
|
||||
修复后: cbbe.../1771915755_audio_xxxxxxxx.wav → 上传成功
|
||||
```
|
||||
|
||||
| 文件 | 变更 |
|
||||
|------|------|
|
||||
| `backend/app/modules/ref_audios/service.py` | 新增 `sanitize_filename()` 函数,上传路径使用清洗后文件名 |
|
||||
239
Docs/DevLogs/Day26.md
Normal file
239
Docs/DevLogs/Day26.md
Normal file
@@ -0,0 +1,239 @@
|
||||
## 🎨 前端优化:板块合并 + 序号标题 + UI 精细化 (Day 26)
|
||||
|
||||
### 概述
|
||||
|
||||
首页原有 9 个独立板块(左栏 7 个 + 右栏 2 个),每个都有自己的卡片容器和标题,视觉碎片化严重。本次将相关板块合并为 5 个主板块,添加中文序号(一~十),移除 emoji 图标,并对多个子组件的布局和交互细节进行优化。
|
||||
|
||||
---
|
||||
|
||||
## ✅ 改动内容
|
||||
|
||||
### 1. 板块合并方案
|
||||
|
||||
**左栏(4 个主板块 + 2 个独立区域):**
|
||||
|
||||
| 序号 | 板块名 | 子板块 | 原组件 |
|
||||
|------|--------|--------|--------|
|
||||
| 一 | 文案提取与编辑 | — | ScriptEditor |
|
||||
| 二 | 标题与字幕 | — | TitleSubtitlePanel |
|
||||
| 三 | 配音 | 配音方式 / 配音列表 | VoiceSelector + GeneratedAudiosPanel |
|
||||
| 四 | 素材编辑 | 视频素材 / 时间轴编辑 | MaterialSelector + TimelineEditor |
|
||||
| 五 | 背景音乐 | — | BgmPanel |
|
||||
| — | 生成按钮 | — | GenerateActionBar(不编号) |
|
||||
|
||||
**右栏(1 个主板块):**
|
||||
|
||||
| 序号 | 板块名 | 子板块 | 原组件 |
|
||||
|------|--------|--------|--------|
|
||||
| 六 | 作品 | 作品列表 / 作品预览 | HistoryList + PreviewPanel |
|
||||
|
||||
**发布页(/publish):**
|
||||
|
||||
| 序号 | 板块名 |
|
||||
|------|--------|
|
||||
| 七 | 平台账号 |
|
||||
| 八 | 选择发布作品 |
|
||||
| 九 | 发布信息 |
|
||||
| 十 | 选择发布平台 |
|
||||
|
||||
### 2. embedded 模式
|
||||
|
||||
6 个组件新增 `embedded?: boolean` prop(默认 `false`):
|
||||
|
||||
- `VoiceSelector` — embedded 时不渲染外层卡片和主标题
|
||||
- `GeneratedAudiosPanel` — embedded 时两行布局:第 1 行(语速+生成配音右对齐)、第 2 行(配音列表+刷新)
|
||||
- `MaterialSelector` — embedded 时自渲染 h3 子标题"视频素材"+ 上传/刷新按钮同行
|
||||
- `TimelineEditor` — embedded 时自渲染 h3 子标题"时间轴编辑"+ 画面比例/播放控件同行
|
||||
- `PreviewPanel` — embedded 时不渲染外层卡片和标题
|
||||
- `HistoryList` — embedded 时不渲染外层卡片和标题(刷新按钮由 HomePage 提供)
|
||||
|
||||
### 3. 序号标题 + emoji 移除
|
||||
|
||||
所有编号板块移除 emoji 图标,使用纯中文序号:
|
||||
|
||||
- ScriptEditor: `✍️ 文案提取与编辑` → `一、文案提取与编辑`
|
||||
- TitleSubtitlePanel: `🎬 标题与字幕` → `二、标题与字幕`
|
||||
- BgmPanel: `🎵 背景音乐` → `五、背景音乐`
|
||||
- HomePage 右栏: `五、作品` → `六、作品`
|
||||
- PublishPage: `👤 平台账号` → `七、平台账号`、`📹 选择发布作品` → `八、选择发布作品`、`✍️ 发布信息` → `九、发布信息`、`📱 选择发布平台` → `十、选择发布平台`
|
||||
|
||||
### 4. 子标题与分隔样式
|
||||
|
||||
- **主标题**: `text-base sm:text-lg font-semibold text-white`
|
||||
- **子标题**: `text-sm font-medium text-gray-400`
|
||||
- **分隔线**: `<div className="border-t border-white/10 my-4" />`
|
||||
|
||||
### 5. 配音列表布局优化
|
||||
|
||||
GeneratedAudiosPanel embedded 模式下采用两行布局:
|
||||
- **第 1 行**:语速下拉 + 生成配音按钮(右对齐,`flex justify-end`)
|
||||
- **第 2 行**:`<h3>配音列表</h3>` + 刷新按钮(两端对齐)
|
||||
- 非 embedded 模式保持原单行布局
|
||||
|
||||
### 6. TitleSubtitlePanel 下拉对齐
|
||||
|
||||
- 标题样式/副标题样式/字幕样式三行标签统一 `w-20`(固定 80px),确保下拉菜单垂直对齐
|
||||
- 下拉菜单宽度 `w-1/3 min-w-[100px]`,避免过宽
|
||||
|
||||
### 7. RefAudioPanel 文案简化
|
||||
|
||||
- 原底部段落"上传任意语音样本(3-10秒)…" 移至 "我的参考音频" 标题旁,简化为 `(上传3-10秒语音样本)`
|
||||
|
||||
### 8. 账户下拉菜单添加手机号
|
||||
|
||||
- AccountSettingsDropdown 在账户有效期上方新增手机号显示区域
|
||||
- 显示 `user?.phone || '未知账户'`
|
||||
|
||||
### 9. 标题显示模式对副标题生效
|
||||
|
||||
- **payload 修复**: `useHomeController.ts` 中 `title_display_mode` 的发送条件从 `videoTitle.trim()` 改为 `videoTitle.trim() || videoSecondaryTitle.trim()`,确保仅有副标题时也能发送显示模式
|
||||
- **UI 调整**: 短暂显示/常驻显示下拉从片头标题输入行移至"二、标题与字幕"板块标题行(与预览样式按钮同行),明确表示该设置对标题和副标题同时生效
|
||||
- Remotion 端 `Title.tsx` 已支持(标题和副标题作为整体组件渲染,`displayMode` 统一控制)
|
||||
|
||||
### 10. 时间轴模糊遮罩
|
||||
|
||||
遮罩从外层 wrapper 移入"四、素材编辑"卡片内,仅覆盖时间轴子区域(`rounded-xl`)。
|
||||
|
||||
### 11. 登录后用户信息立即可用
|
||||
|
||||
- AuthContext 新增 `setUser` 方法暴露给消费者
|
||||
- 登录页成功后调用 `setUser(result.user)` 立即写入 Context,无需等页面刷新
|
||||
- 修复登录后账户下拉显示"未知账户"、刷新后才显示手机号的问题
|
||||
|
||||
### 12. 文案与选项微调
|
||||
|
||||
- MaterialSelector 描述 `(可多选,最多4个)` → `(上传自拍视频,最多可选4个)`
|
||||
- TitleSubtitlePanel 显示模式选项 `短暂显示/常驻显示` → `标题短暂显示/标题常驻显示`
|
||||
|
||||
### 13. UI/UX 体验优化(6 项)
|
||||
|
||||
- **操作按钮移动端可见**: 配音列表、作品列表、素材列表、参考音频、历史文案的操作按钮从 `opacity-0`(hover 才显示)改为 `opacity-40`(平时半透明可见,hover 全亮),解决触屏设备无法发现按钮的问题
|
||||
- **手机号脱敏**: AccountSettingsDropdown 手机号中间四位遮掩 `138****5678`
|
||||
- **标题字数计数器**: TitleSubtitlePanel 标题/副标题输入框右侧显示实时字数 `3/15`,超限变红
|
||||
- **列表滚动条提示**: ~~配音列表、作品列表、素材列表、BGM 列表从 `hide-scrollbar` 改为 `custom-scrollbar`~~ → 已全部改回 `hide-scrollbar` 隐藏滚动条(滚动功能不变)
|
||||
- **时间轴拖拽提示**: TimelineEditor 色块左上角新增 `GripVertical` 抓手图标,暗示可拖拽排序
|
||||
- **截取滑块放大**: ClipTrimmer 手柄从 16px 放大到 20px,触控区从 32px 放大到 40px
|
||||
|
||||
### 14. 代码质量修复(4 项)
|
||||
|
||||
- **AccountSettingsDropdown**: 关闭密码弹窗补齐 `setSuccess('')` 清空
|
||||
- **MaterialSelector**: `selectedSet` 加 `useMemo` 避免每次渲染重建
|
||||
- **TimelineEditor**: `visibleSegments`/`overflowSegments` 加 `useMemo`
|
||||
- **MaterialSelector**: 素材满 4 个时非选中项按钮加 `disabled`
|
||||
|
||||
### 15. 发布页平台账号响应式布局
|
||||
|
||||
- **单行布局**:图标+名称+状态在左,按钮在右(`flex items-center`)
|
||||
- **移动端紧凑**:图标 `h-6 w-6`、按钮 `text-xs px-2 py-1 rounded-md`、间距 `space-y-2 px-3 py-2.5`
|
||||
- **桌面端宽松**:`sm:h-7 sm:w-7`、`sm:text-sm sm:px-3 sm:py-1.5 sm:rounded-lg`、`sm:space-y-3 sm:px-4 sm:py-3.5`
|
||||
- 两端各自美观,风格与其他板块一致
|
||||
|
||||
### 16. 移动端刷新回顶部修复
|
||||
|
||||
- **问题**: 移动端刷新页面后不回到顶部,而是滚动到背景音乐板块
|
||||
- **根因**: 1) 浏览器原生滚动恢复覆盖 `scrollTo(0,0)`;2) 列表 scroll effect 有双依赖(`selectedId` + `list`),数据异步加载时第二次触发跳过了 ref 守卫,执行了 `scrollIntoView` 导致页面跳动
|
||||
- **修复**: 三管齐下 — ① `history.scrollRestoration = "manual"` 禁用浏览器原生恢复;② 时间门控 `scrollEffectsEnabled` ref(1 秒内禁止所有列表自动滚动)替代单次 ref 守卫;③ 200ms 延迟兜底 `scrollTo(0,0)`
|
||||
|
||||
### 17. 移动端样式预览窗口缩小
|
||||
|
||||
- **问题**: 移动端点击"预览样式"后窗口占满整屏(宽 358px,高约 636px),遮挡样式调节控件
|
||||
- **修复**: 移动端宽度从 `window.innerWidth - 32` 缩小到 **160px**;位置从左上角改为**右下角**(`right:12, bottom:12`),不遮挡上方控件;最大高度限制 `50dvh`
|
||||
- 桌面端保持不变(280px,左上角)
|
||||
|
||||
### 18. 列表滚动条统一隐藏
|
||||
|
||||
- 将 Day 26 早期改为 `custom-scrollbar`(细紫色滚动条)的 7 处全部改回 `hide-scrollbar`
|
||||
- 涉及:BgmPanel、GeneratedAudiosPanel、HistoryList、MaterialSelector(2处)、ScriptExtractionModal(2处)
|
||||
- 滚动功能不受影响,仅视觉上不显示滚动条
|
||||
|
||||
### 19. 配音按钮移动端适配
|
||||
|
||||
- VoiceSelector "选择声音/克隆声音" 按钮:内边距 `px-4` → `px-2 sm:px-4`,字号加 `text-sm sm:text-base`,图标加 `shrink-0`
|
||||
- 修复移动端窄屏下按钮被挤压导致"克隆声音"不可见的问题
|
||||
|
||||
### 20. 素材标题溢出修复
|
||||
|
||||
- MaterialSelector embedded 标题行移除 `whitespace-nowrap`
|
||||
- 描述文字 `(上传自拍视频,最多可选4个)` 在移动端隐藏(`hidden sm:inline`),桌面端正常显示
|
||||
- 修复移动端刷新按钮被推出容器外的问题
|
||||
|
||||
### 21. 生成配音按钮放大
|
||||
|
||||
- "生成配音" 作为核心操作按钮,从辅助尺寸升级为主操作尺寸
|
||||
- 内边距 `px-2/px-3 py-1/py-1.5` → `px-4 py-2`,字号 `text-xs` → `text-sm font-medium`
|
||||
- 图标 `h-3.5 w-3.5` → `h-4 w-4`,新增 `shadow-sm` + hover `shadow-md`
|
||||
- embedded 与非 embedded 模式统一放大
|
||||
|
||||
### 22. 生成进度条位置调整
|
||||
|
||||
- **问题**: 生成进度条在"六、作品"卡片内部(作品预览下方),不够醒目
|
||||
- **修复**: 进度条从 PreviewPanel 内部提取到 HomePage 右栏,作为独立卡片渲染在"六、作品"卡片**上方**
|
||||
- 使用紫色边框(`border-purple-500/30`)区分,显示任务消息和百分比
|
||||
- PreviewPanel embedded 模式下不再渲染进度条(传入 `currentTask={null}`)
|
||||
- 生成完成后进度卡片自动消失
|
||||
|
||||
### 23. LatentSync 超时修复
|
||||
|
||||
- **问题**: 约 2 分钟的视频(3023 帧,190 段推理)预计推理 54 分钟,但 httpx 超时仅 20 分钟,导致 LatentSync 调用失败并回退到无口型同步
|
||||
- **根因**: `lipsync_service.py` 中 `httpx.AsyncClient(timeout=1200.0)` 不足以覆盖长视频推理时间
|
||||
- **修复**: 超时从 `1200s`(20 分钟)改为 `3600s`(1 小时),足以覆盖 2-3 分钟视频的推理
|
||||
|
||||
### 24. 字幕时间戳节奏映射(修复长视频字幕漂移)
|
||||
|
||||
- **问题**: 2 分钟视频字幕明显对不上语音,越到后面偏差越大
|
||||
- **根因**: `whisper_service.py` 的 `original_text` 处理逻辑丢弃了 Whisper 逐词时间戳,仅保留总时间范围后做全程线性插值,每个字分配相同时长,完全忽略语速变化和停顿
|
||||
- **修复**: 保留 Whisper 的逐字时间戳作为语音节奏模板,将原文字符按比例映射到 Whisper 时间节奏上(rhythm-mapping),而非线性均分。字幕文字不变,只是时间戳跟随真实语速
|
||||
- **算法**: 原文第 i 个字符映射到 Whisper 时间线的 `(i/N)*M` 位置(N=原文字符数,M=Whisper字符数),在相邻 Whisper 时间点间线性插值
|
||||
|
||||
---
|
||||
|
||||
## 📁 修改文件清单
|
||||
|
||||
| 文件 | 改动 |
|
||||
|------|------|
|
||||
| `VoiceSelector.tsx` | 新增 embedded prop,移动端按钮适配(`px-2 sm:px-4`) |
|
||||
| `GeneratedAudiosPanel.tsx` | 新增 embedded prop,两行布局,操作按钮可见度,"生成配音"按钮放大 |
|
||||
| `MaterialSelector.tsx` | 新增 embedded prop,自渲染子标题+操作按钮,useMemo,disabled 守卫,操作按钮可见度,标题溢出修复 |
|
||||
| `TimelineEditor.tsx` | 新增 embedded prop,自渲染子标题+控件,useMemo,拖拽抓手图标 |
|
||||
| `PreviewPanel.tsx` | 新增 embedded prop |
|
||||
| `HistoryList.tsx` | 新增 embedded prop,操作按钮可见度 |
|
||||
| `ScriptEditor.tsx` | 标题加序号,移除 emoji,操作按钮可见度 |
|
||||
| `TitleSubtitlePanel.tsx` | 标题加序号,移除 emoji,下拉对齐,显示模式下拉上移,字数计数器 |
|
||||
| `BgmPanel.tsx` | 标题加序号 |
|
||||
| `HomePage.tsx` | 核心重构:合并板块、序号标题、生成配音按钮迁入、`scrollRestoration` + 延迟兜底修复刷新回顶部、生成进度条提取到作品卡片上方 |
|
||||
| `PublishPage.tsx` | 四个板块加序号(七~十),移除 emoji,平台卡片响应式单行布局 |
|
||||
| `RefAudioPanel.tsx` | 简化提示文案,操作按钮可见度 |
|
||||
| `AccountSettingsDropdown.tsx` | 新增手机号显示(脱敏),补齐 success 清空 |
|
||||
| `AuthContext.tsx` | 新增 `setUser` 方法,登录后立即更新用户状态 |
|
||||
| `login/page.tsx` | 登录成功后调用 `setUser` 写入用户数据 |
|
||||
| `useHomeController.ts` | titleDisplayMode 条件修复,列表 scroll 时间门控 `scrollEffectsEnabled` |
|
||||
| `FloatingStylePreview.tsx` | 移动端预览窗口缩小(160px)并移至右下角 |
|
||||
| `ScriptExtractionModal.tsx` | 滚动条改回隐藏 |
|
||||
| `ClipTrimmer.tsx` | 滑块手柄放大、触控区增高 |
|
||||
| `lipsync_service.py` | httpx 超时从 1200s 改为 3600s |
|
||||
| `whisper_service.py` | 字幕时间戳从线性插值改为 Whisper 节奏映射 |
|
||||
|
||||
---
|
||||
|
||||
## 🔍 验证
|
||||
|
||||
- `npm run build` — 零报错零警告
|
||||
- 合并后布局:各子板块分隔清晰、主标题有序号
|
||||
- 向后兼容:`embedded` 默认 `false`,组件独立使用不受影响
|
||||
- 配音列表两行布局:语速+生成配音在上,配音列表+刷新在下
|
||||
- 下拉菜单垂直对齐正确
|
||||
- 短暂显示/常驻显示对标题和副标题同时生效
|
||||
- 操作按钮在移动端(触屏)可见
|
||||
- 手机号脱敏显示
|
||||
- 标题字数计数器正常
|
||||
- 列表滚动条全部隐藏
|
||||
- 时间轴拖拽抓手图标显示
|
||||
- 发布页平台卡片:移动端紧凑、桌面端宽松,风格一致
|
||||
- 移动端刷新后回到顶部,不再滚动到背景音乐位置
|
||||
- 移动端样式预览窗口不遮挡控件
|
||||
- 移动端配音按钮(选择声音/克隆声音)均可见
|
||||
- 移动端素材标题行按钮不溢出
|
||||
- 生成配音按钮视觉层级高于辅助按钮
|
||||
- 生成进度条在作品卡片上方独立显示
|
||||
- LatentSync 长视频推理不再超时回退
|
||||
- 字幕时间戳与语音节奏同步,长视频不漂移
|
||||
231
Docs/DevLogs/Day27.md
Normal file
231
Docs/DevLogs/Day27.md
Normal file
@@ -0,0 +1,231 @@
|
||||
## Remotion 描边修复 + 字体样式扩展 + TypeScript 修复 (Day 27)
|
||||
|
||||
### 概述
|
||||
|
||||
修复标题/字幕描边渲染问题(描边过粗 + 副标题重影),扩展字体样式选项(标题 4→12、字幕 4→8),修复 Remotion 项目 TypeScript 类型错误。
|
||||
|
||||
---
|
||||
|
||||
## ✅ 改动内容
|
||||
|
||||
### 1. 描边渲染修复(标题 + 字幕)
|
||||
|
||||
- **问题**: 标题黑色描边过粗,副标题出现重影/鬼影
|
||||
- **根因**: `buildTextShadow` 用 4 方向 `textShadow` 模拟描边 — 对角线叠加导致描边视觉上比实际 `stroke_size` 更粗;4 角方向在中间有间隙和叠加,造成重影
|
||||
- **修复**: 改用 CSS 原生描边 `-webkit-text-stroke` + `paint-order: stroke fill`(Remotion 用 Chromium 渲染,完美支持)
|
||||
- **旧方案**:
|
||||
```javascript
|
||||
textShadow: `-8px -8px 0 #000, 8px -8px 0 #000, -8px 8px 0 #000, 8px 8px 0 #000, 0 0 16px rgba(0,0,0,0.5), 0 2px 4px rgba(0,0,0,0.3)`
|
||||
```
|
||||
- **新方案**:
|
||||
```javascript
|
||||
WebkitTextStroke: `5px #000000`,
|
||||
paintOrder: 'stroke fill',
|
||||
textShadow: `0 2px 4px rgba(0,0,0,0.3)`,
|
||||
```
|
||||
- 同时将所有预设样式的 `stroke_size` 从 8 降到 5,配合原生描边视觉更干净
|
||||
|
||||
### 2. 字体样式扩展
|
||||
|
||||
**标题样式**: 4 个 → 12 个(+8)
|
||||
|
||||
| ID | 样式名 | 字体 | 配色 |
|
||||
|----|--------|------|------|
|
||||
| title_pangmen | 庞门正道 | 庞门正道标题体3.0 | 白字黑描 |
|
||||
| title_round | 优设标题圆 | 优设标题圆 | 白字紫描 |
|
||||
| title_alibaba | 阿里数黑体 | 阿里巴巴数黑体 | 白字黑描 |
|
||||
| title_chaohei | 文道潮黑 | 文道潮黑 | 青蓝字深蓝描 |
|
||||
| title_wujie | 无界黑 | 标小智无界黑 | 白字深灰描 |
|
||||
| title_houdi | 厚底黑 | Aa厚底黑 | 红字深黑描 |
|
||||
| title_banyuan | 寒蝉半圆体 | 寒蝉半圆体 | 白字黑描 |
|
||||
| title_jixiang | 欣意吉祥宋 | 字体圈欣意吉祥宋 | 金字棕描 |
|
||||
|
||||
**字幕样式**: 4 个 → 8 个(+4)
|
||||
|
||||
| ID | 样式名 | 字体 | 高亮色 |
|
||||
|----|--------|------|--------|
|
||||
| subtitle_pink | 少女粉 | DingTalk JinBuTi | 粉色 #FF69B4 |
|
||||
| subtitle_lime | 清新绿 | DingTalk Sans | 荧光绿 #76FF03 |
|
||||
| subtitle_gold | 金色隶书 | 阿里妈妈刀隶体 | 金色 #FDE68A |
|
||||
| subtitle_kai | 楷体红字 | SimKai | 红色 #FF4444 |
|
||||
|
||||
### 3. TypeScript 类型错误修复
|
||||
|
||||
- **Root.tsx**: `Composition` 泛型类型与 `calculateMetadata` 参数类型不匹配 — 内联 `calculateMetadata` 并显式标注参数类型,`defaultProps` 使用 `satisfies VideoProps` 约束
|
||||
- **Video.tsx**: `VideoProps` 接口添加 `[key: string]: unknown` 索引签名,兼容 Remotion 要求的 `Record<string, unknown>` 约束
|
||||
- **VideoLayer.tsx**: `OffthreadVideo` 组件不支持 `loop` prop — 移除(该 prop 原本就被忽略)
|
||||
|
||||
### 4. 进度条文案还原
|
||||
|
||||
- **问题**: 进度条显示后端推送的详细阶段消息(如"正在合成唇型"),用户希望只显示"正在AI生成中..."
|
||||
- **修复**: `HomePage.tsx` 进度条文案从 `{currentTask.message || "正在AI生成中..."}` 改为固定 `正在AI生成中...`
|
||||
|
||||
---
|
||||
|
||||
## 📁 修改文件清单
|
||||
|
||||
| 文件 | 改动 |
|
||||
|------|------|
|
||||
| `remotion/src/components/Title.tsx` | `buildTextShadow` → `buildStrokeStyle`(CSS 原生描边),标题+副标题同时生效 |
|
||||
| `remotion/src/components/Subtitles.tsx` | `buildTextShadow` → `buildStrokeStyle`(CSS 原生描边) |
|
||||
| `remotion/src/Root.tsx` | 修复 `Composition` 泛型类型、`calculateMetadata` 参数类型 |
|
||||
| `remotion/src/Video.tsx` | `VideoProps` 添加索引签名 |
|
||||
| `remotion/src/components/VideoLayer.tsx` | 移除 `OffthreadVideo` 不支持的 `loop` prop |
|
||||
| `backend/assets/styles/title.json` | 标题样式从 4 个扩展到 12 个,`stroke_size` 8→5 |
|
||||
| `backend/assets/styles/subtitle.json` | 字幕样式从 4 个扩展到 8 个 |
|
||||
| `frontend/.../HomePage.tsx` | 进度条文案还原为固定"正在AI生成中..." |
|
||||
|
||||
---
|
||||
|
||||
## 🔍 验证
|
||||
|
||||
- `npx tsc --noEmit` — 零错误
|
||||
- `npm run build:render` — 渲染脚本编译成功
|
||||
- `npm run build`(前端)— 零报错
|
||||
- 描边:标题/副标题/字幕使用 CSS 原生描边,无重影、无虚胖
|
||||
- 样式选择:前端下拉可加载全部 12 个标题 + 8 个字幕样式
|
||||
|
||||
---
|
||||
|
||||
## 视频生成流水线性能优化
|
||||
|
||||
### 概述
|
||||
|
||||
针对视频生成流水线进行全面性能优化,涵盖 FFmpeg 编码参数、LatentSync 推理参数、多素材并行化、以及后处理阶段并行化。预估 15s 单素材视频从 ~280s 降至 ~190s (32%),30s 双素材从 ~400s 降至 ~240s (40%)。
|
||||
|
||||
**服务器配置**: 2x RTX 3090 (24GB), 2x Xeon E5-2680 v4 (56核), 192GB RAM
|
||||
|
||||
### 第一阶段:FFmpeg 编码优化
|
||||
|
||||
**最终合成 preset `slow` → `medium`**
|
||||
- 合成阶段从 ~50s 降到 ~25s,质量几乎无变化
|
||||
|
||||
**中间文件 CRF 18 → 23**
|
||||
- 中间产物(trim、prepare_segment、concat、loop、normalize_orientation)不是最终输出,不需要高质量编码
|
||||
- 每个中间步骤快 3-8 秒
|
||||
|
||||
**最终合成 CRF 18 → 20**
|
||||
- 15 秒口播视频 CRF 18 vs 20 肉眼无法区分
|
||||
|
||||
### 第二阶段:LatentSync 推理参数调优
|
||||
|
||||
**inference_steps 20 → 16**
|
||||
- 推理时间线性减少 20%(~180s → ~144s)
|
||||
|
||||
**guidance_scale 2.0 → 1.5**
|
||||
- classifier-free guidance 权重降低,每步计算量微降(5-10%)
|
||||
|
||||
> ⚠️ 两项需重启 LatentSync 服务后测试唇形质量,确认可接受再保留。如质量不佳可回退 .env 参数。
|
||||
|
||||
### 第三阶段:多素材流水线并行化
|
||||
|
||||
**素材下载 + 归一化并行**
|
||||
- 串行 `for` 循环改为 `asyncio.gather()`,`normalize_orientation` 通过 `run_in_executor` 在线程池执行
|
||||
- N 个素材从串行 N×5s → ~5s
|
||||
|
||||
**片段预处理并行**
|
||||
- 逐个 `prepare_segment` 改为 `asyncio.gather()` + `run_in_executor`
|
||||
- 2 素材 ~90s → ~50s;4 素材 ~180s → ~60s
|
||||
|
||||
### 第四阶段:流水线交叠
|
||||
|
||||
**Whisper 字幕对齐 与 BGM 混音 并行**
|
||||
- 两者互不依赖(都只依赖 audio_path),用 `asyncio.gather()` 并行执行
|
||||
- 单素材模式下 Whisper 从 LatentSync 之后的串行步骤移至与 BGM 并行
|
||||
- 不开 BGM 或不开字幕时行为不变,只有同时启用时才并行
|
||||
|
||||
### 修改文件
|
||||
|
||||
| 文件 | 改动 |
|
||||
|------|------|
|
||||
| `backend/app/services/video_service.py` | compose: preset slow→medium, CRF 18→20; normalize_orientation/prepare_segment/concat: CRF 18→23 |
|
||||
| `backend/app/services/lipsync_service.py` | _loop_video_to_duration: CRF 18→23 |
|
||||
| `backend/.env` | LATENTSYNC_INFERENCE_STEPS=16, LATENTSYNC_GUIDANCE_SCALE=1.5 |
|
||||
| `backend/app/modules/videos/workflow.py` | import asyncio; 素材下载/归一化并行; 片段预处理并行; Whisper+BGM 并行 |
|
||||
|
||||
### 回退方案
|
||||
|
||||
- FFmpeg 参数:如画质不满意,将最终 CRF 改回 18、preset 改回 slow
|
||||
- LatentSync:如唇形质量下降,将 .env 中 `INFERENCE_STEPS` 改回 20、`GUIDANCE_SCALE` 改回 2.0
|
||||
- 并行化:纯架构优化,无质量影响,无需回退
|
||||
|
||||
---
|
||||
|
||||
## MuseTalk + LatentSync 混合唇形同步方案
|
||||
|
||||
### 概述
|
||||
|
||||
LatentSync 1.6 质量高但推理极慢(~78% 总时长),长视频(>=2min)耗时 20-60 分钟不可接受。MuseTalk 1.5 是单步潜空间修复(非扩散模型),逐帧推理速度接近实时(30fps+ on V100),适合长视频。混合方案按音频时长自动路由:短视频用 LatentSync 保质量,长视频用 MuseTalk 保速度。
|
||||
|
||||
### 架构
|
||||
|
||||
- **路由阈值**: `LIPSYNC_DURATION_THRESHOLD` (默认 120s)
|
||||
- **短视频 (<120s)**: LatentSync 1.6 (GPU1, 端口 8007)
|
||||
- **长视频 (>=120s)**: MuseTalk 1.5 (GPU0, 端口 8011)
|
||||
- **回退**: MuseTalk 不可用时自动 fallback 到 LatentSync
|
||||
|
||||
### 改动文件
|
||||
|
||||
| 文件 | 改动 |
|
||||
|------|------|
|
||||
| `models/MuseTalk/` | 从 Temp/MuseTalk 复制代码 + 下载权重 |
|
||||
| `models/MuseTalk/scripts/server.py` | 新建 FastAPI 常驻服务 (端口 8011, GPU0) |
|
||||
| `backend/app/core/config.py` | 新增 MUSETALK_* 和 LIPSYNC_DURATION_THRESHOLD |
|
||||
| `backend/.env` | 新增对应环境变量 |
|
||||
| `backend/app/services/lipsync_service.py` | 新增 `_call_musetalk_server()` + 混合路由逻辑 + 扩展 `check_health()` |
|
||||
|
||||
---
|
||||
|
||||
## MuseTalk 推理性能优化 (server.py v2)
|
||||
|
||||
### 概述
|
||||
|
||||
MuseTalk 首次长视频测试 (136s, 3404 帧) 耗时 1799s (~30 分钟),分析发现瓶颈集中在人脸检测 (28%)、BiSeNet 合成 (22%)、I/O (17%),而非 UNet 推理本身 (17%)。通过 6 项优化预估降至 8-10 分钟 (~3x 加速)。
|
||||
|
||||
### 性能瓶颈分析 (优化前, 1799s)
|
||||
|
||||
| 阶段 | 耗时 | 占比 | 瓶颈原因 |
|
||||
|------|------|------|---------|
|
||||
| DWPose + 人脸检测 | ~510s | 28% | `batch_size_fa=1`, 每帧跑 2 个 NN, 完全串行 |
|
||||
| 合成 + BiSeNet 人脸解析 | ~400s | 22% | 每帧都跑 BiSeNet + PNG 写盘 |
|
||||
| UNet 推理 | ~300s | 17% | batch_size=8 太小 |
|
||||
| I/O (PNG 读写 + FFmpeg) | ~300s | 17% | PNG 压缩慢, ffmpeg→PNG→imread 链路 |
|
||||
| VAE 编码 | ~100s | 6% | 逐帧编码, 未批处理 |
|
||||
|
||||
### 6 项优化
|
||||
|
||||
| # | 优化项 | 详情 |
|
||||
|---|--------|------|
|
||||
| 1 | **batch_size 8→32** | `.env` 修改, RTX 3090 显存充裕 |
|
||||
| 2 | **cv2.VideoCapture 直读帧** | 跳过 ffmpeg→PNG→imread 链路, 省去 3404 次 PNG 编解码 |
|
||||
| 3 | **人脸检测降频 (每5帧)** | 每 5 帧运行 DWPose + FaceAlignment, 中间帧线性插值 bbox |
|
||||
| 4 | **BiSeNet mask 缓存 (每5帧)** | 每 5 帧运行 `get_image_prepare_material`, 中间帧用 `get_image_blending` 复用缓存 mask |
|
||||
| 5 | **cv2.VideoWriter 直写** | 跳过逐帧 PNG 写盘 + ffmpeg 重编码, 用 VideoWriter 直写 mp4 |
|
||||
| 6 | **每阶段计时** | 7 个阶段精确计时, 方便后续进一步调优 |
|
||||
|
||||
### 修改文件
|
||||
|
||||
| 文件 | 改动 |
|
||||
|------|------|
|
||||
| `models/MuseTalk/scripts/server.py` | 完全重写 `_run_inference()`, 新增 `_detect_faces_subsampled()` |
|
||||
| `backend/.env` | `MUSETALK_BATCH_SIZE` 8→32 |
|
||||
|
||||
---
|
||||
|
||||
## Remotion 并发渲染优化
|
||||
|
||||
### 概述
|
||||
|
||||
Remotion 渲染在 56 核服务器上默认只用 8 并发 (`min(8, cores/2)`),改为 16 并发,预估从 ~5 分钟降到 ~2-3 分钟。
|
||||
|
||||
### 改动
|
||||
|
||||
- `remotion/render.ts`: `renderMedia()` 新增 `concurrency` 参数 (默认 16), 支持 `--concurrency` CLI 参数覆盖
|
||||
- `remotion/dist/render.js`: 重新编译
|
||||
|
||||
### 修改文件
|
||||
|
||||
| 文件 | 改动 |
|
||||
|------|------|
|
||||
| `remotion/render.ts` | `RenderOptions` 新增 `concurrency` 字段, `renderMedia()` 传入 `concurrency` |
|
||||
| `remotion/dist/render.js` | TypeScript 重新编译 |
|
||||
263
Docs/DevLogs/Day28.md
Normal file
263
Docs/DevLogs/Day28.md
Normal file
@@ -0,0 +1,263 @@
|
||||
## CosyVoice FP16 加速 + 文档更新 + AI改写界面重构 + 标题字幕面板重排与视频帧预览 (Day 28)
|
||||
|
||||
### 概述
|
||||
|
||||
CosyVoice 3.0 声音克隆服务开启 FP16 半精度推理,预估提速 30-40%。同步更新 4 个项目文档。重构 AI 改写文案界面(RewriteModal 两步流程 + ScriptExtractionModal 逻辑抽取)。前端将"标题与字幕"面板从第二步移至第四步(素材编辑之后),样式预览窗口背景从紫粉渐变改为视频片头帧截图,实现所见即所得。
|
||||
|
||||
---
|
||||
|
||||
## ✅ 改动内容
|
||||
|
||||
### 1. CosyVoice FP16 半精度加速
|
||||
|
||||
- **问题**: CosyVoice 3.0 以 FP32 全精度运行,RTF (Real-Time Factor) 约 0.9-1.35x,生成 2 分钟音频需要约 2 分钟
|
||||
- **根因**: `AutoModel()` 初始化时未传入 `fp16=True`,LLM 推理和 Flow Matching (DiT) 均在 FP32 下运行
|
||||
- **修复**: 一行改动开启 FP16 自动混合精度
|
||||
|
||||
```python
|
||||
# 旧: _model = AutoModel(model_dir=str(MODEL_DIR))
|
||||
# 新:
|
||||
_model = AutoModel(model_dir=str(MODEL_DIR), fp16=True)
|
||||
```
|
||||
|
||||
- **生效机制**: `CosyVoice3Model` 在 `llm_job()` 和 `token2wav()` 中通过 `torch.cuda.amp.autocast(self.fp16)` 自动将计算转为 FP16
|
||||
- **预期效果**:
|
||||
- 推理速度提升 30-40%
|
||||
- 显存占用降低 ~30%
|
||||
- 语音质量基本无损(0.5B 模型 FP16 精度充足)
|
||||
- **验证**: 服务重启后自检通过,健康检查 `ready: true`
|
||||
|
||||
### 2. 文档全面更新 (4 个文件)
|
||||
|
||||
补充 Day 27 新增的 MuseTalk 混合唇形同步方案、性能优化、Remotion 并发渲染等内容到所有相关文档。
|
||||
|
||||
#### README.md
|
||||
- 项目描述更新为 "LatentSync 1.6 + MuseTalk 1.5 混合唇形同步"
|
||||
- 唇形同步功能描述改为混合方案(短视频 LatentSync,长视频 MuseTalk)
|
||||
- 技术栈表新增 MuseTalk 1.5
|
||||
- 项目结构新增 `models/MuseTalk/`
|
||||
- 服务架构表新增 MuseTalk (端口 8011)
|
||||
- 文档中心新增 MuseTalk 部署指南链接
|
||||
- 性能优化描述新增降频检测 + Remotion 16 并发
|
||||
|
||||
#### DEPLOY_MANUAL.md
|
||||
- GPU 分配说明更新 (GPU0=MuseTalk+CosyVoice, GPU1=LatentSync)
|
||||
- 步骤 3 拆分为 3a (LatentSync) + 3b (MuseTalk)
|
||||
- 环境变量表新增 7 个 MuseTalk 变量,移除过时的 `DOUYIN_COOKIE`
|
||||
- LatentSync 推理步数默认值 20→16
|
||||
- 测试运行新增 MuseTalk 启动终端
|
||||
- PM2 管理新增 MuseTalk 服务(第 5 项)
|
||||
- 端口检查、日志查看命令新增 8011/vigent2-musetalk
|
||||
|
||||
#### SUBTITLE_DEPLOY.md
|
||||
- 技术架构图更新为 LatentSync/MuseTalk 混合路由
|
||||
- 新增唇形同步路由说明
|
||||
- Remotion 配置表新增 `concurrency` 参数 (默认 16)
|
||||
- GPU 分配说明更新
|
||||
- 更新日志新增 v1.3.0 条目
|
||||
|
||||
#### BACKEND_README.md
|
||||
- 健康检查接口描述更新为含 LatentSync + MuseTalk + 混合路由阈值
|
||||
- 环境变量配置新增 MuseTalk 相关变量
|
||||
- 服务集成指南新增"唇形同步混合路由"章节
|
||||
|
||||
---
|
||||
|
||||
### 3. AI 改写文案界面重构
|
||||
|
||||
#### RewriteModal 重构
|
||||
|
||||
将 AI 改写弹窗改为两步式流程,提升交互体验:
|
||||
|
||||
**第一步 — 配置与触发**:
|
||||
- 自定义提示词输入(可选),自动持久化到 localStorage
|
||||
- "开始改写"按钮触发 `/api/ai/rewrite` 请求
|
||||
|
||||
**第二步 — 结果对比与选择**:
|
||||
- 上方:AI 改写结果 + "使用此结果"按钮(紫粉渐变色,醒目)
|
||||
- 下方:原文对比 + "保留原文"按钮(灰色低调)
|
||||
- 底部:可"重新改写"(重回第一步,保留自定义提示词)
|
||||
- ESC 快捷键关闭
|
||||
|
||||
#### ScriptExtractionModal 逻辑抽取
|
||||
|
||||
将文案提取模态框的全部业务逻辑抽取到独立 hook `useScriptExtraction`:
|
||||
|
||||
- **useScriptExtraction.ts** (新建): 管理 URL/文件双模式输入、拖拽上传、提取请求、步骤状态机 (config → processing → result)、剪贴板复制
|
||||
- **ScriptExtractionModal.tsx**: 纯展示组件,消费 hook 返回值,新增 ESC/Enter 快捷键
|
||||
|
||||
#### ScriptEditor 工具栏调整
|
||||
|
||||
- 按钮组右对齐 (`justify-end`),统一高度 `h-7` 和圆角
|
||||
- "历史文案"按钮用灰色 (bg-gray-600) 区分辅助功能
|
||||
- "文案提取助手"用紫色 (bg-purple-600) 表示主功能
|
||||
- "AI多语言"用绿渐变 (emerald-teal),"AI生成标题标签"用蓝渐变 (blue-cyan)
|
||||
- "AI智能改写"和"保存文案"移至文本框下方状态栏
|
||||
|
||||
---
|
||||
|
||||
### 4. 标题字幕面板重排 + 视频帧背景预览
|
||||
|
||||
#### 面板顺序重排
|
||||
|
||||
将 `<TitleSubtitlePanel>` 从第二步移至第四步(素材编辑之后),使用户在设置标题字幕样式时已经完成了素材选择和时间轴编排。
|
||||
|
||||
新顺序:
|
||||
```
|
||||
一、文案提取与编辑(不变)
|
||||
二、配音(原三)
|
||||
三、素材编辑(原四)
|
||||
四、标题与字幕(原二)→ 移到素材编辑之后
|
||||
```
|
||||
|
||||
#### 新建 useVideoFrameCapture hook
|
||||
|
||||
从视频 URL 截取 0.1s 处帧画面,返回 JPEG data URL:
|
||||
|
||||
- 创建 `<video>` 元素,设置 `crossOrigin="anonymous"`(素材存储在 Supabase Storage 跨域地址)
|
||||
- 先绑定 `loadedmetadata` / `canplay` / `seeked` / `error` 事件监听,再设 src(避免事件丢失)
|
||||
- `loadedmetadata` 或 `canplay` 触发后 seek 到 0.1s,`seeked` 回调中用 canvas `drawImage` 截帧
|
||||
- canvas 缩放到 480px 宽再编码(预览窗口最大 280px,节省内存)
|
||||
- `canvas.toDataURL("image/jpeg", 0.7)` 导出
|
||||
- 防御 `videoWidth/videoHeight` 为 0 的边界情况
|
||||
- try-catch 防 canvas taint,失败返回 null(降级渐变)
|
||||
- `isActive` 标志 + `seeked` 去重标志防止 stale 和重复更新
|
||||
- 截图完成后清理 video 元素释放内存
|
||||
|
||||
#### 按需截取(性能优化)
|
||||
|
||||
只在样式预览窗口打开时才触发截取:
|
||||
|
||||
```typescript
|
||||
const materialPosterUrl = useVideoFrameCapture(
|
||||
showStylePreview ? firstTimelineMaterialUrl : null
|
||||
);
|
||||
```
|
||||
|
||||
截取源优先使用**时间轴第一段素材**(用户拖拽排序后的真实片头),回退到 `selectedMaterials[0]`(未生成配音、时间轴为空时)。
|
||||
|
||||
#### 预览背景替换
|
||||
|
||||
`FloatingStylePreview` 有视频帧时直接显示原始画面(不加半透明,保证颜色真实),文字靠描边保证可读性;无视频帧时降级为原紫粉渐变背景。
|
||||
|
||||
#### 踩坑记录
|
||||
|
||||
1. **CORS tainted canvas**: 素材文件存储在 Supabase Storage (`api.hbyrkj.top`),是跨域签名链接。必须设 `video.crossOrigin = "anonymous"` 才能让 canvas `toDataURL` 不被 SecurityError 拦截
|
||||
2. **时间轴为空**: `useTimelineEditor` 在 `audioDuration <= 0`(未选配音)时返回空数组,需回退到 `selectedMaterials[0]`
|
||||
3. **事件监听顺序**: 必须先绑定事件监听再设 `video.src`,否则快速加载时事件可能丢失
|
||||
|
||||
---
|
||||
|
||||
## 📁 修改文件清单
|
||||
|
||||
| 文件 | 改动 |
|
||||
|------|------|
|
||||
| `models/CosyVoice/cosyvoice_server.py` | `AutoModel()` 新增 `fp16=True` 参数 |
|
||||
| `README.md` | 混合唇形同步描述、技术栈、服务架构、项目结构更新 |
|
||||
| `Docs/DEPLOY_MANUAL.md` | MuseTalk 部署步骤、环境变量、PM2 管理、端口检查 |
|
||||
| `Docs/SUBTITLE_DEPLOY.md` | 架构图、Remotion concurrency、GPU 分配、更新日志 |
|
||||
| `Docs/BACKEND_README.md` | 健康检查、环境变量、混合路由章节 |
|
||||
| `frontend/.../RewriteModal.tsx` | 两步式改写流程(自定义提示词 → 结果对比) |
|
||||
| `frontend/.../script-extraction/useScriptExtraction.ts` | **新建** — 文案提取逻辑 hook |
|
||||
| `frontend/.../ScriptExtractionModal.tsx` | 纯展示组件,消费 hook,新增快捷键 |
|
||||
| `frontend/.../ScriptEditor.tsx` | 工具栏右对齐 + 按钮分色 + 改写/保存移至底部 |
|
||||
| `frontend/.../useVideoFrameCapture.ts` | **新建** — 视频帧截取 hook,crossOrigin + canvas 缩放 |
|
||||
| `frontend/.../useHomeController.ts` | 新增 useMemo 计算素材 URL,调用帧截取 hook,showStylePreview 门控 |
|
||||
| `frontend/.../HomePage.tsx` | 面板重排(二↔四互换),编号更新,透传 materialPosterUrl |
|
||||
| `frontend/.../TitleSubtitlePanel.tsx` | 编号"二"→"四",新增 previewBackgroundUrl prop |
|
||||
| `frontend/.../FloatingStylePreview.tsx` | 新增 previewBackgroundUrl prop,条件渲染视频帧/渐变背景 |
|
||||
|
||||
---
|
||||
|
||||
## 🔍 验证
|
||||
|
||||
- CosyVoice 重启成功,健康检查 `{"ready": true}`
|
||||
- 自检推理通过(7.2s for "你好")
|
||||
- FP16 通过 `torch.cuda.amp.autocast(self.fp16)` 在 LLM 和 Flow Matching 阶段生效
|
||||
- `npx tsc --noEmit` — 零错误
|
||||
- AI 改写:自定义提示词持久化 → 改写结果 + 原文对比 → "使用此结果"/"保留原文"
|
||||
- 文案提取:URL / 文件双模式 → 处理中动画 → 结果填入
|
||||
- 面板顺序:一→文案、二→配音、三→素材编辑、四→标题与字幕
|
||||
- 样式预览背景:有素材时显示真实视频片头帧,无素材降级紫粉渐变
|
||||
- 预览关闭时不触发截取,不浪费资源
|
||||
|
||||
---
|
||||
|
||||
## 💡 CosyVoice 性能分析备注
|
||||
|
||||
### 当前性能基线 (FP32, 优化前)
|
||||
|
||||
| 文本长度 | 音频时长 | 推理耗时 | RTF |
|
||||
|----------|----------|----------|-----|
|
||||
| 42 字 | 9.8s | 13.2s | 1.35x |
|
||||
| 89 字 | 18.2s | 20.3s | 1.12x |
|
||||
| ~530 字 | 115.8s | 107.7s | 0.93x |
|
||||
| ~670 字 | 143.5s | 131.6s | 0.92x |
|
||||
|
||||
### 未来可选优化(收益递减,暂不实施)
|
||||
|
||||
| 优化项 | 预期提升 | 复杂度 |
|
||||
|--------|----------|--------|
|
||||
| TensorRT (DiT 模块) | +20-30% | 需编译 .plan 引擎 |
|
||||
| torch.compile() | +10-20% | 一行代码,但首次编译慢 |
|
||||
| vLLM (LLM 模块) | +10-15% | 额外依赖 |
|
||||
|
||||
---
|
||||
|
||||
## MuseTalk 合成阶段性能优化
|
||||
|
||||
### 概述
|
||||
|
||||
MuseTalk v2 优化后总耗时从 1799s 降到 819s(2.2x),但合成阶段(Phase 6)仍占 462.2s (56.4%),是最大单一瓶颈。本次优化两个方向:纯 numpy blending 替代 PIL 转换、FFmpeg pipe + NVENC GPU 硬编码替代双重编码。
|
||||
|
||||
### 1. 纯 numpy blending 替代 PIL(blending.py)
|
||||
|
||||
- **问题**: `get_image_blending` 每帧做 3 次 numpy↔PIL 转换 + BGR↔RGB 通道翻转,纯粹浪费
|
||||
- **方案**: 新增 `get_image_blending_fast()` 函数
|
||||
- 全程保持 BGR numpy 数组,不做 PIL 转换和通道翻转
|
||||
- mask 混合用 numpy 向量化广播 `mask * (1/255)` 替代 `PIL.paste with mask`
|
||||
- 原 `get_image_blending` 保留作为 fallback
|
||||
- **降级链**: `blending_fast` → `blending`(PIL)→ `get_image`(完整重算)
|
||||
|
||||
### 2. FFmpeg pipe + NVENC 硬编码替代双重编码(server.py)
|
||||
|
||||
**优化前(双重编码)**:
|
||||
```
|
||||
Phase 6: 逐帧 → cv2.VideoWriter (mp4v CPU 软编码) → temp_raw.mp4
|
||||
Phase 7: FFmpeg 读 temp_raw.mp4 → H.264 CPU 重编码 + 合并音频 → output.mp4
|
||||
```
|
||||
|
||||
**优化后(单次 GPU 编码)**:
|
||||
```
|
||||
Phase 6: 逐帧 → FFmpeg stdin pipe (rawvideo → h264_nvenc GPU 编码) → temp_raw.mp4
|
||||
Phase 7: FFmpeg 只做音频合并 (-c:v copy -c:a copy) → output.mp4 (秒级)
|
||||
```
|
||||
|
||||
- NVENC 参数: `-c:v h264_nvenc -preset p4 -cq 20 -pix_fmt yuv420p`
|
||||
- RTX 3090 NVENC 专用芯片编码,不占 CUDA 核心,编码速度 >500fps
|
||||
|
||||
### 3. FFmpeg 进程资源管理加固
|
||||
|
||||
- `try/finally` 包裹写帧循环,确保异常时 `proc.stdin.close()` 执行
|
||||
- `proc.wait()` 后读 stderr 再关闭,避免缓冲区死锁
|
||||
- stderr decode 加 `errors="ignore"` 防止非 UTF-8 崩溃
|
||||
|
||||
### 4. `run_ffmpeg` 安全改进
|
||||
|
||||
- 去掉 `shell=True`,改用列表传参,避免路径特殊字符导致命令注入
|
||||
- Phase 7 FFmpeg 命令从字符串拼接改为列表传参
|
||||
|
||||
### 调优过程
|
||||
|
||||
| 版本 | Phase 6 | Phase 7 | 总计 | 结论 |
|
||||
|------|---------|---------|------|------|
|
||||
| Day27 基线 | 462s | 38s | 819s | — |
|
||||
| v1: libx264 -preset medium | 548s | 0.3s | 854s | CPU 编码背压,反而更慢 |
|
||||
| v2: h264_nvenc(当前) | 待测 | 待测 | 待测 | NVENC 零背压,预估 Phase 6 < 200s |
|
||||
|
||||
### 修改文件
|
||||
|
||||
| 文件 | 改动 |
|
||||
|------|------|
|
||||
| `models/MuseTalk/musetalk/utils/blending.py` | 新增 `get_image_blending_fast()` 纯 numpy 函数 |
|
||||
| `models/MuseTalk/scripts/server.py` | Phase 6: FFmpeg pipe + NVENC + blending_fast;Phase 7: -c:v copy;`run_ffmpeg` 去掉 shell=True |
|
||||
283
Docs/DevLogs/Day29.md
Normal file
283
Docs/DevLogs/Day29.md
Normal file
@@ -0,0 +1,283 @@
|
||||
## 字幕同步修复 + 嘴型参数调优 + 视频流水线全面优化 + 预览背景修复 + CosyVoice 语气控制 (Day 29)
|
||||
|
||||
### 概述
|
||||
|
||||
本轮对视频生成流水线做全面审查优化:修复字幕与语音不同步问题(Whisper 时间戳平滑 + 原文节奏映射)、调优 LatentSync 嘴型参数、compose 流复制省去冗余重编码、FFmpeg 超时保护、全局并发限制、Redis 任务 TTL、临时文件清理、死代码移除。修复因前端域名迁移导致的样式预览背景 CORS 失效问题。新增 CosyVoice 语气控制功能,声音克隆模式下支持开心/伤心/生气等情绪表达(基于 `inference_instruct2`)。
|
||||
|
||||
---
|
||||
|
||||
## ✅ 改动内容
|
||||
|
||||
### 1. 字幕同步修复(Whisper 时间戳 + 原文节奏映射)
|
||||
|
||||
- **问题**: 字幕高亮与语音不同步,表现为字幕超前/滞后、高亮跳空
|
||||
- **根因**: Whisper 输出的逐字时间戳存在微小抖动(相邻字 end > 下一字 start),且字间间隙导致高亮"闪烁"
|
||||
|
||||
#### whisper_service.py — 时间戳后处理
|
||||
|
||||
新增 `smooth_word_timestamps()` 函数,三步平滑:
|
||||
|
||||
1. **单调递增保证**: 后一字的 start 不早于前一字的 start
|
||||
2. **重叠消除**: 两字时间重叠时取中点分割
|
||||
3. **间隙填补**: 字间间隙 < 50ms 时直接连接,避免高亮跳空
|
||||
|
||||
```python
|
||||
def smooth_word_timestamps(words):
|
||||
for i in range(1, len(words)):
|
||||
# 重叠 → 中点分割
|
||||
if w["start"] < prev["end"]:
|
||||
mid = (prev["end"] + w["start"]) / 2
|
||||
prev["end"] = mid; w["start"] = mid
|
||||
# 微小间隙 → 直接连接
|
||||
if 0 < gap < 0.05:
|
||||
prev["end"] = w["start"]
|
||||
```
|
||||
|
||||
#### whisper_service.py — 原文节奏映射
|
||||
|
||||
- **问题**: AI 改写/多语言文案与 Whisper 转录文字不一致,直接用 Whisper 文字会乱码
|
||||
- **方案**: `original_text` 参数非空时,用原文字符替换 Whisper 文字,但保留 Whisper 的语音节奏时间戳
|
||||
- 实现:将 N 个原文字符按比例映射到 M 个 Whisper 时间戳上(线性插值)
|
||||
- 字数比例异常检测(>1.5x 或 <0.67x 时警告)
|
||||
- 单字时长钳位:40ms ~ 800ms,防止极端漂移
|
||||
|
||||
#### captions.ts — Remotion 端字幕查找
|
||||
|
||||
新增 `getCurrentSegment()` 和 `getCurrentWordIndex()` 函数:
|
||||
|
||||
- 根据当前帧时间精确查找应显示的字幕段落和高亮字索引
|
||||
- 处理字间间隙(两字之间返回前一字索引,保持高亮连续)
|
||||
- 超过最后一字结束时间时返回最后一字(避免末尾闪烁)
|
||||
|
||||
---
|
||||
|
||||
### 2. LatentSync 嘴型参数调优
|
||||
|
||||
| 参数 | Day28 值 | Day29 值 | 说明 |
|
||||
|------|----------|----------|------|
|
||||
| `LATENTSYNC_INFERENCE_STEPS` | 16 | 20 | 适当增加步数提升嘴型质量 |
|
||||
| `LATENTSYNC_GUIDANCE_SCALE` | (默认) | 2.0 | 平衡嘴型贴合度与自然感 |
|
||||
| `LATENTSYNC_ENABLE_DEEPCACHE` | (默认) | true | DeepCache 加速推理 |
|
||||
| `LATENTSYNC_SEED` | (默认) | 1247 | 固定种子保证可复现 |
|
||||
| Remotion concurrency | 16 | 4 | 降低并发防止资源争抢 |
|
||||
|
||||
---
|
||||
|
||||
### 3. compose() 流复制替代冗余重编码(高优先级)
|
||||
|
||||
**文件**: `video_service.py`
|
||||
|
||||
- **问题**: `compose()` 只是合并视频轨+音频轨(mux),却每次用 `libx264 -preset medium -crf 20` 做完整重编码,耗时数分钟。整条流水线一个视频最多被 x264 编码 5 次
|
||||
- **方案**: 不需要循环时(`loop_count == 1`)用 `-c:v copy` 流复制,几乎瞬间完成;需要循环时仍用 libx264
|
||||
|
||||
```python
|
||||
if loop_count > 1:
|
||||
cmd.extend(["-c:v", "libx264", "-preset", "fast", "-crf", "23"])
|
||||
else:
|
||||
cmd.extend(["-c:v", "copy"])
|
||||
```
|
||||
|
||||
- compose 是中间产物(Remotion 会再次编码),流复制省一次编码且无质量损失
|
||||
|
||||
---
|
||||
|
||||
### 4. FFmpeg 超时保护(高优先级)
|
||||
|
||||
**文件**: `video_service.py`
|
||||
|
||||
- `_run_ffmpeg()`: 新增 `timeout=600`(10 分钟),捕获 `subprocess.TimeoutExpired`
|
||||
- `_get_duration()`: 新增 `timeout=30`
|
||||
- 防止畸形视频导致 FFmpeg 永久挂起阻塞后台任务
|
||||
|
||||
---
|
||||
|
||||
### 5. 全局任务并发限制(高优先级)
|
||||
|
||||
**文件**: `workflow.py`
|
||||
|
||||
- 模块级 `asyncio.Semaphore(2)`,`process_video_generation()` 入口 acquire
|
||||
- 排队中的任务显示"排队中..."状态
|
||||
- 防止多个请求同时跑 FFmpeg + Remotion 导致 CPU/内存爆炸
|
||||
|
||||
```python
|
||||
_generation_semaphore = asyncio.Semaphore(2)
|
||||
|
||||
async def process_video_generation(task_id, req, user_id):
|
||||
_update_task(task_id, message="排队中...")
|
||||
async with _generation_semaphore:
|
||||
await _process_video_generation_inner(task_id, req, user_id)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 6. Redis 任务 TTL + 索引清理(中优先级)
|
||||
|
||||
**文件**: `task_store.py`
|
||||
|
||||
- `create()`: 设 24 小时 TTL(`ex=86400`)
|
||||
- `update()`: completed/failed 状态设 2 小时 TTL(`ex=7200`),其余 24 小时
|
||||
- `list()`: 遍历时顺带清理已过期的索引条目(`srem`)
|
||||
- 解决 Redis 任务 key 永久堆积问题
|
||||
|
||||
---
|
||||
|
||||
### 7. 临时字体文件清理(中优先级)
|
||||
|
||||
**文件**: `workflow.py`
|
||||
|
||||
- `prepare_style_for_remotion()` 复制字体到 temp_dir,但未加入清理列表
|
||||
- 现在遍历三组前缀(subtitle/title/secondary_title)× 四种扩展名(.ttf/.otf/.woff/.woff2),将存在的字体文件加入 `temp_files`
|
||||
|
||||
---
|
||||
|
||||
### 8. Whisper+split 逻辑去重(低优先级)
|
||||
|
||||
**文件**: `workflow.py`
|
||||
|
||||
- 两个分支(custom_assignments 不匹配 vs 默认)的 Whisper→_split_equal 代码 100% 相同(36 行重复)
|
||||
- 提取为内部函数 `_whisper_and_split()`,两个分支共用
|
||||
|
||||
---
|
||||
|
||||
### 9. LipSync 死代码清理(低优先级)
|
||||
|
||||
**文件**: `lipsync_service.py`
|
||||
|
||||
- 删除 `_preprocess_video()` 方法(92 行),全项目无任何调用
|
||||
|
||||
---
|
||||
|
||||
### 10. 标题字幕预览背景 CORS 修复
|
||||
|
||||
- **问题**: 前端域名从 `vigent.hbyrkj.top` 迁移到 `ipagent.ai-labz.cn` 后,素材签名 URL(`api.hbyrkj.top`)与新前端域名完全不同根域,Supabase Kong 网关的 CORS 不覆盖新域名 → `<video crossOrigin="anonymous">` 加载失败 → canvas 截帧失败 → 回退渐变背景
|
||||
- **根因**: Day28 实现依赖 Supabase 返回 `Access-Control-Allow-Origin` 头,换域名后此依赖断裂
|
||||
|
||||
**修复方案 — 同源代理(彻底绕开 CORS)**:
|
||||
|
||||
| 组件 | 改动 |
|
||||
|------|------|
|
||||
| `materials/router.py` | 新增 `GET /api/materials/stream/{material_id}` 端点,通过 `get_local_file_path()` 从本地磁盘直读,返回 `FileResponse` |
|
||||
| `useHomeController.ts` | 帧截取 URL 改为 `/api/materials/stream/${mat.id}`(同源),不再用跨域签名 URL |
|
||||
| `useVideoFrameCapture.ts` | 移除 `crossOrigin = "anonymous"`,同源请求不需要 |
|
||||
|
||||
链路:`用户点预览 → /api/materials/stream/xxx → Next.js rewrite → FastAPI FileResponse → 同源 <video> → canvas 截帧成功`
|
||||
|
||||
---
|
||||
|
||||
### 11. 支付宝回调域名更新
|
||||
|
||||
**文件**: `.env`
|
||||
|
||||
```
|
||||
ALIPAY_NOTIFY_URL=https://ipagent.ai-labz.cn/api/payment/notify
|
||||
ALIPAY_RETURN_URL=https://ipagent.ai-labz.cn/pay
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📁 修改文件清单
|
||||
|
||||
| 文件 | 改动 |
|
||||
|------|------|
|
||||
| `backend/app/services/whisper_service.py` | 时间戳平滑 + 原文节奏映射 + 单字时长钳位 |
|
||||
| `remotion/src/utils/captions.ts` | 新增 `getCurrentSegment` / `getCurrentWordIndex` |
|
||||
| `backend/app/services/video_service.py` | compose 流复制 + FFmpeg 超时保护 |
|
||||
| `backend/app/modules/videos/workflow.py` | Semaphore(2) 并发限制 + 字体清理 + Whisper 逻辑去重 |
|
||||
| `backend/app/modules/videos/task_store.py` | Redis TTL + 索引过期清理 |
|
||||
| `backend/app/services/lipsync_service.py` | 删除 `_preprocess_video()` 死代码 |
|
||||
| `backend/app/services/remotion_service.py` | concurrency 16 → 4 |
|
||||
| `remotion/render.ts` | 新增 concurrency 参数支持 |
|
||||
| `backend/app/modules/materials/router.py` | 新增 `/stream/{material_id}` 同源代理端点 |
|
||||
| `frontend/.../useVideoFrameCapture.ts` | 移除 crossOrigin |
|
||||
| `frontend/.../useHomeController.ts` | 帧截取 URL 改用同源代理 |
|
||||
| `backend/.env` | 嘴型参数 + 支付宝域名更新 |
|
||||
|
||||
---
|
||||
|
||||
### 12. CosyVoice 语气控制功能
|
||||
|
||||
- **功能**: 声音克隆模式下新增"语气"下拉菜单(正常/欢快/低沉/严肃),利用 CosyVoice3 的 `inference_instruct2()` 方法通过自然语言指令控制语气情绪
|
||||
- **默认行为不变**: 选择"正常"时仍走 `inference_zero_shot()`,与改动前完全一致
|
||||
|
||||
#### 数据流
|
||||
|
||||
```
|
||||
用户选择语气 → setEmotion("happy") → localStorage 持久化
|
||||
→ 生成配音 → emotion 映射为 instruct_text
|
||||
→ POST /api/generated-audios/generate { instruct_text }
|
||||
→ voice_clone_service → POST localhost:8010/generate { instruct_text }
|
||||
→ instruct_text 非空 ? inference_instruct2() : inference_zero_shot()
|
||||
```
|
||||
|
||||
#### CosyVoice 服务 — `cosyvoice_server.py`
|
||||
|
||||
- `/generate` 端点新增 `instruct_text: str = Form("")` 参数
|
||||
- 推理分支:空 → `inference_zero_shot()`,非空 → `inference_instruct2(text, instruct_text, ref_audio_path, ...)`
|
||||
- `inference_instruct2` 不需要 `prompt_text`,直接接受 `instruct_text` + `prompt_wav`
|
||||
|
||||
#### 后端透传
|
||||
|
||||
- `schemas.py`: `GenerateAudioRequest` 新增 `instruct_text: Optional[str] = None`
|
||||
- `service.py`: `generate_audio_task()` voiceclone 分支传递 `instruct_text=req.instruct_text or ""`
|
||||
- `voice_clone_service.py`: `_generate_once()` 和 `generate_audio()` 新增 `instruct_text` 参数
|
||||
|
||||
#### 前端
|
||||
|
||||
- `useHomeController.ts`: 新增 `emotion` state + `emotionToInstruct` 映射表
|
||||
- `useHomePersistence.ts`: 语气选择持久化到 localStorage
|
||||
- `useGeneratedAudios.ts`: `generateAudio` params 新增 `instruct_text`
|
||||
- `GeneratedAudiosPanel.tsx`: 语气下拉菜单(语速按钮左侧),复用语速下拉样式,仅 voiceclone 模式可见
|
||||
- `HomePage.tsx`: 透传 `emotion`/`onEmotionChange`
|
||||
|
||||
#### instruct_text 格式(来自 CosyVoice3 instruct_list)
|
||||
|
||||
```
|
||||
正常: ""(走 inference_zero_shot)
|
||||
欢快: "You are a helpful assistant. 请非常开心地说一句话。<|endofprompt|>"
|
||||
低沉: "You are a helpful assistant. 请非常伤心地说一句话。<|endofprompt|>"
|
||||
严肃: "You are a helpful assistant. 请非常生气地说一句话。<|endofprompt|>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📁 修改文件清单
|
||||
|
||||
| 文件 | 改动 |
|
||||
|------|------|
|
||||
| `backend/app/services/whisper_service.py` | 时间戳平滑 + 原文节奏映射 + 单字时长钳位 |
|
||||
| `remotion/src/utils/captions.ts` | 新增 `getCurrentSegment` / `getCurrentWordIndex` |
|
||||
| `backend/app/services/video_service.py` | compose 流复制 + FFmpeg 超时保护 |
|
||||
| `backend/app/modules/videos/workflow.py` | Semaphore(2) 并发限制 + 字体清理 + Whisper 逻辑去重 |
|
||||
| `backend/app/modules/videos/task_store.py` | Redis TTL + 索引过期清理 |
|
||||
| `backend/app/services/lipsync_service.py` | 删除 `_preprocess_video()` 死代码 |
|
||||
| `backend/app/services/remotion_service.py` | concurrency 16 → 4 |
|
||||
| `remotion/render.ts` | 新增 concurrency 参数支持 |
|
||||
| `backend/app/modules/materials/router.py` | 新增 `/stream/{material_id}` 同源代理端点 |
|
||||
| `frontend/.../useVideoFrameCapture.ts` | 移除 crossOrigin |
|
||||
| `frontend/.../useHomeController.ts` | 帧截取 URL 改用同源代理 + emotion state + emotionToInstruct 映射 |
|
||||
| `backend/.env` | 嘴型参数 + 支付宝域名更新 |
|
||||
| `models/CosyVoice/cosyvoice_server.py` | `/generate` 新增 `instruct_text` 参数,分支 `inference_instruct2` / `inference_zero_shot` |
|
||||
| `backend/app/services/voice_clone_service.py` | `_generate_once` / `generate_audio` 新增 `instruct_text` 透传 |
|
||||
| `backend/app/modules/generated_audios/schemas.py` | `GenerateAudioRequest` 新增 `instruct_text` 字段 |
|
||||
| `backend/app/modules/generated_audios/service.py` | voiceclone 分支传递 `instruct_text` |
|
||||
| `frontend/.../useGeneratedAudios.ts` | `generateAudio` params 新增 `instruct_text` |
|
||||
| `frontend/.../useHomePersistence.ts` | emotion 持久化 (localStorage) |
|
||||
| `frontend/.../GeneratedAudiosPanel.tsx` | 语气下拉菜单 UI (embedded + standalone) |
|
||||
| `frontend/.../HomePage.tsx` | 透传 emotion / onEmotionChange |
|
||||
|
||||
---
|
||||
|
||||
## 🔍 验证
|
||||
|
||||
1. **字幕同步**: 生成视频观察逐字高亮,不应出现超前/滞后/跳空
|
||||
2. **compose 流复制**: FFmpeg 日志中 compose 步骤应出现 `-c:v copy`,耗时从分钟级降到秒级
|
||||
3. **FFmpeg 超时**: 代码确认 timeout 参数已加
|
||||
4. **并发限制**: 连续提交 3 个任务,第 3 个应显示"排队中",前 2 个完成后才开始
|
||||
5. **Redis TTL**: `redis-cli TTL vigent:tasks:<id>` 确认有过期时间
|
||||
6. **字体清理**: 生成视频后 temp 目录不应残留字体文件
|
||||
7. **预览背景**: 选择素材 → 点击"预览样式",应显示视频第一帧(非渐变)
|
||||
8. **支付宝**: 发起支付后回调和跳转地址为新域名
|
||||
9. **语气控制**: 声音克隆模式选择"开心"/"生气"生成配音,CosyVoice 日志出现 `🎭 Instruct mode`,音频语气有明显变化
|
||||
10. **语气默认**: 选择"正常"时行为与改动前完全相同(走 `inference_zero_shot`)
|
||||
11. **语气持久化**: 切换语气后刷新页面,下拉菜单恢复上次选择
|
||||
12. **语气可见性**: 语气下拉仅在 voiceclone 模式显示,edgetts 模式不显示
|
||||
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`
|
||||
526
Docs/DevLogs/Day31.md
Normal file
526
Docs/DevLogs/Day31.md
Normal file
@@ -0,0 +1,526 @@
|
||||
## 文档分层收敛 + 音色试听修复 + 录音弹窗重构 + 弹窗体系统一 (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-6 pb-6`)
|
||||
- 角标样式进一步极简化:仅保留双箭头图标,去掉外框容器并贴近输入框边缘
|
||||
- 角标位置微调为更协调的“上移+右移”:`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` ✅
|
||||
|
||||
---
|
||||
|
||||
## ✅ 13) 站点 Icon 替换(使用 `Temp/video.png`)
|
||||
|
||||
### 变更
|
||||
|
||||
- 将提供的 `Temp/video.png` 转换并替换为站点图标资源
|
||||
- 新增 `frontend/src/app/icon.png`(Next App Router icon 资源)
|
||||
- 更新 `frontend/src/app/favicon.ico`(16/32/48/64 多尺寸)
|
||||
|
||||
### 验证
|
||||
|
||||
- `npm run build`(frontend)✅
|
||||
- 构建产物包含 `/icon.png` 路由 ✅
|
||||
- `pm2 restart vigent2-frontend` ✅
|
||||
|
||||
---
|
||||
|
||||
## ✅ 14) 发布后工作区清理链路加固(CleanupContext + `/api/videos/cleanup`)
|
||||
|
||||
### 14.1 功能落地
|
||||
|
||||
- 发布页新增“全平台发布成功后清理引导”链路:
|
||||
- 全平台成功:触发 `CleanupModal`
|
||||
- 任一平台失败:保持原内联结果展示
|
||||
- `CleanupModal` 支持展示:成功平台列表、成功截图、下载视频备份、一键清理
|
||||
- 清理状态 `cleanup_pending` 持久化到 localStorage,刷新/跳转后可恢复
|
||||
|
||||
### 14.2 稳定性与防锁死优化
|
||||
|
||||
- 后端删除能力改为“异常上抛”,避免静默吞错导致前端误判清理成功
|
||||
- 清理接口改为严格成功语义:
|
||||
- 视频和配音删除都成功才返回 success
|
||||
- 任一删除失败直接返回错误,前端保留弹窗并允许重试
|
||||
- 前端清理动作改为“先后端、后本地”:
|
||||
- 后端失败:不清本地、不关弹窗
|
||||
- 后端成功:再清理本地输入字段并关闭弹窗
|
||||
- 后端成功清理后前端派发 `vigent:workspace-cleared` 事件,发布页就地重置标题/标签输入态(无需手动刷新)
|
||||
- 连续失败达到阈值(3 次)后显示“暂不清理,继续使用”,避免异常环境下永久阻塞
|
||||
- 清理弹窗增加 24h 过期,避免跨天残留状态
|
||||
- 用户切换/登出时重置 cleanup 状态,避免旧账号状态串扰
|
||||
|
||||
### 14.3 清理范围口径
|
||||
|
||||
- 仅清理输入内容字段:
|
||||
- 首页:文案/标题/副标题
|
||||
- 发布页:标题/标签
|
||||
- 保留用户偏好字段(样式、字号、边距、模型、BGM 等)
|
||||
|
||||
### 验证
|
||||
|
||||
- `python -m py_compile backend/app/services/storage.py backend/app/modules/videos/service.py backend/app/modules/generated_audios/service.py backend/app/modules/videos/router.py` ✅
|
||||
- `npm run build`(frontend)✅
|
||||
- `pm2 restart vigent2-backend && pm2 restart vigent2-frontend` ✅
|
||||
- `curl http://127.0.0.1:8006/health` 返回 `{"status":"ok"}` ✅
|
||||
|
||||
---
|
||||
|
||||
## 📁 今日主要修改文件
|
||||
|
||||
| 文件 | 改动 |
|
||||
|------|------|
|
||||
| `backend/app/modules/videos/router.py` | 新增/增强 `voice-preview` GET+POST,试听文本 locale 路由,临时文件清理;新增 `POST /api/videos/cleanup` 严格成功语义 |
|
||||
| `backend/app/modules/videos/service.py` | 新增批量删除生成视频能力;返回 `(deleted, failed)` 供 cleanup 路由判定 |
|
||||
| `backend/app/modules/generated_audios/service.py` | 新增批量删除预生成配音能力;返回 `(deleted, failed)` 供 cleanup 路由判定 |
|
||||
| `backend/app/services/storage.py` | `delete_file()` 改为异常上抛,避免删除失败静默吞错造成“假成功” |
|
||||
| `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);全平台发布成功时触发 `triggerCleanup()`,失败保持内联结果;监听 `workspace-cleared` 事件就地清空发布输入态 |
|
||||
| `frontend/src/shared/contexts/CleanupContext.tsx` | 新增发布后清理弹窗与持久化状态;失败不关闭/不清本地、3 次失败可跳过、24h 过期、用户切换复位;清理范围收敛为输入内容字段;成功清理后派发 `workspace-cleared` 事件 |
|
||||
| `frontend/src/app/layout.tsx` | 在 `TaskProvider` 内挂载 `CleanupProvider`,确保全局可触发发布后清理弹窗 |
|
||||
| `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/app/icon.png` | 新增站点 icon 资源(来自 `Temp/video.png`) |
|
||||
| `frontend/src/app/favicon.ico` | 替换站点 favicon(由 `video.png` 转换为多尺寸 ico) |
|
||||
| `frontend/src/features/publish/ui/PublishPage.tsx` | 扫码登录(QR)弹窗迁移到 `AppModal` + 二维码白底留白容器优化(避免边缘观感被裁) |
|
||||
| `Docs/FRONTEND_DEV.md` | 新增统一弹窗规范(AppModal)和录音交互规范;补充文案扩展编辑也统一走 AppModal;新增 CleanupContext 清理策略规范 |
|
||||
| `Docs/FRONTEND_README.md` | 增补录音入口与弹窗交互说明;明确“标题常驻显示”对主/副标题同时生效;补充文案输入框扩展编辑器说明;补充发布后清理弹窗失败兜底说明 |
|
||||
| `Docs/BACKEND_README.md` | 增补 `voice-preview` 接口说明;更新发布 API 路径(`/login/{platform}` 等)并链接发布专项文档;补充 `title_display_mode` 对主/副标题统一生效说明;新增 `/api/videos/cleanup` 接口说明 |
|
||||
| `Docs/BACKEND_DEV.md` | 更新后端规范中的发布器覆盖范围与小红书配置项;补充发布专项文档指引;补充 `title_display_mode` 主/副标题统一生效约定;新增 cleanup 严格成功语义约定 |
|
||||
| `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)✅
|
||||
- 站点 icon 替换后构建通过,产物包含 `/icon.png` 路由 ✅
|
||||
- `pm2 restart vigent2-frontend`(icon 替换后)✅
|
||||
- `python -m py_compile backend/app/services/storage.py backend/app/modules/videos/service.py backend/app/modules/generated_audios/service.py backend/app/modules/videos/router.py`(cleanup 链路加固后)✅
|
||||
- `npm run build`(CleanupContext 优化后)✅
|
||||
- `pm2 restart vigent2-backend && pm2 restart vigent2-frontend`(cleanup 链路加固后)✅
|
||||
- `curl http://127.0.0.1:8006/health` 返回 `{"status":"ok"}`(cleanup 链路加固后)✅
|
||||
- `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 与部署说明口径
|
||||
- 回写 `Docs/FRONTEND_README.md` / `Docs/FRONTEND_DEV.md` / `Docs/PUBLISH_DEPLOY.md`,补齐发布后清理弹窗与 cleanup 接口联动说明
|
||||
- 回写 `README.md`,补充发布专项文档入口与小红书发布成功截图能力描述
|
||||
- 回写 `Docs/TASK_COMPLETE.md`,补齐 Day31 任务完成记录
|
||||
- 回写 `Docs/DOC_RULES.md`,同步文档更新规则到当前文档结构与工具链
|
||||
- 首页「AI生成标题标签」按钮迁移到「四、标题与字幕」并固定标题同层最右;显示方式与预览下沉到下一行右侧
|
||||
- 文案输入框右下角新增扩展角标,支持弹出大编辑器进行长文案编辑
|
||||
- 站点 icon 已替换为 `Temp/video.png` 对应资源(`app/icon.png` + `app/favicon.ico`)
|
||||
- 发布后工作区清理链路落地(CleanupModal + `/api/videos/cleanup`)并补齐失败兜底(失败不关弹窗、不清本地)
|
||||
- 清理链路防锁死优化:3 次失败可跳过、24h 过期、用户切换复位
|
||||
- 文档补充:`标题短暂显示/常驻显示` 对主标题与副标题统一生效(常驻=主/副标题全程显示)
|
||||
- 非 bilibili 平台 cookie 保存为 `storage_state` 格式
|
||||
- 小红书登录二维码自动切换(短信登录 -> 扫码登录)与提取修复
|
||||
- 对应构建/重启/冒烟验证记录
|
||||
- 今日运行期产物(`backend/user_data/**/cookies/*.json`、`watchdog.log`)为会话副产物,不属于代码/文档变更项
|
||||
71
Docs/DevLogs/Day32.md
Normal file
71
Docs/DevLogs/Day32.md
Normal file
@@ -0,0 +1,71 @@
|
||||
## 视频下载同源修复 + Day 日志拆分归档 (Day 32)
|
||||
|
||||
### 概述
|
||||
|
||||
今天主要处理“下载行为不符合预期”的问题:
|
||||
|
||||
1. 修复首页与发布成功弹窗点击下载时被浏览器当作在线播放(新开标签页)的问题。
|
||||
2. 将下载修复开始后的开发内容从 `Day31` 拆分到 `Day32`,保持日志按天清晰归档。
|
||||
|
||||
---
|
||||
|
||||
## ✅ 1) 视频下载链路修复(避免新开标签页播放)
|
||||
|
||||
### 问题现象
|
||||
|
||||
- 首页“下载视频”与发布成功弹窗“下载视频备份”在部分浏览器会打开新标签页播放视频,而不是直接触发下载。
|
||||
- 根因是跨域签名 URL 场景下,浏览器可能忽略 `<a download>`。
|
||||
|
||||
### 修复方案
|
||||
|
||||
- 后端新增同源下载接口:`GET /api/videos/generated/{video_id}/download`
|
||||
- 使用 `FileResponse` 返回本地视频文件
|
||||
- 显式返回 `Content-Disposition: attachment`
|
||||
- 浏览器直接进入保存文件流程
|
||||
- 发布成功弹窗下载改为传 `videoId`,不再依赖签名 URL。
|
||||
- 首页作品预览下载同步改为同源下载接口,下载行为与发布弹窗统一。
|
||||
- 兼容旧清理状态:`CleanupContext` 对旧 `videoDownloadUrl` 持久化字段做 `videoId` 解析回填。
|
||||
|
||||
---
|
||||
|
||||
## ✅ 2) 配套调整与文档拆分
|
||||
|
||||
### 前端联动
|
||||
|
||||
- `CleanupContext` 继续沿用“清理失败不关弹窗、不清本地”的逻辑,下载链路仅替换为同源接口。
|
||||
- 首页 `PreviewPanel` 支持传入 `generatedVideoId`,下载按钮优先走 `/api/videos/generated/{id}/download`。
|
||||
|
||||
### 日志归档
|
||||
|
||||
- 将“下载修复开始后的内容”从 `Day31` 移出并归档到 `Day32`。
|
||||
- `Day31` 保留 Day31 当日核心内容(到 cleanup 链路加固为止)。
|
||||
|
||||
---
|
||||
|
||||
## 📁 今日主要修改文件
|
||||
|
||||
| 文件 | 改动 |
|
||||
|------|------|
|
||||
| `backend/app/modules/videos/router.py` | 新增 `GET /api/videos/generated/{video_id}/download`,返回 `attachment` 下载响应 |
|
||||
| `frontend/src/features/publish/model/usePublishController.ts` | 发布成功后 `triggerCleanup()` 传 `video.id`(替换签名 URL) |
|
||||
| `frontend/src/shared/contexts/CleanupContext.tsx` | 下载字段改为 `videoId`;兼容旧 `videoDownloadUrl` 回填;下载按钮改同源路径 |
|
||||
| `frontend/src/features/home/ui/PreviewPanel.tsx` | 首页下载改为同源下载接口 |
|
||||
| `frontend/src/features/home/ui/HomePage.tsx` | 透传 `generatedVideoId` 给 `PreviewPanel` |
|
||||
| `Docs/DevLogs/Day31.md` | 移除下载修复章节与对应验证/覆盖项(迁入 Day32) |
|
||||
| `Docs/TASK_COMPLETE.md` | 新增 Day32 Current 区块,Day31 取消 Current |
|
||||
| `Docs/BACKEND_README.md` | 补充 `/api/videos/generated/{video_id}/download` 接口说明 |
|
||||
| `Docs/BACKEND_DEV.md` | 补充下载接口 `attachment` 约定 |
|
||||
| `Docs/FRONTEND_README.md` | 补充首页/发布弹窗下载统一同源接口说明 |
|
||||
| `Docs/FRONTEND_DEV.md` | 补充 CleanupContext 下载策略规范 |
|
||||
| `Docs/PUBLISH_DEPLOY.md` | 补充发布成功后同源下载联动说明 |
|
||||
| `README.md` | 补充“一键下载直达(同源 attachment)”能力描述 |
|
||||
|
||||
---
|
||||
|
||||
## 🔍 验证记录
|
||||
|
||||
- `python -m py_compile backend/app/modules/videos/router.py` ✅
|
||||
- `npm run build`(frontend)✅
|
||||
- `pm2 restart vigent2-frontend` ✅
|
||||
- `pm2 restart vigent2-backend` ✅
|
||||
- `curl http://127.0.0.1:8006/health` 返回 `{"status":"ok"}` ✅
|
||||
@@ -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,13 @@ 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/
|
||||
├── Day1.md # 开发日志
|
||||
@@ -253,16 +267,21 @@ ViGent2/Docs/
|
||||
|
||||
---
|
||||
|
||||
## 📏 内容简洁性规则
|
||||
## 📏 内容简洁性规则
|
||||
|
||||
### 代码示例长度控制
|
||||
- **原则**:只展示关键代码片段(10-20行以内)
|
||||
- **超长代码**:使用 `// ... 省略 ...` 或仅列出文件名+行号
|
||||
- **完整代码**:引用文件链接,而非粘贴全文
|
||||
|
||||
### 调试信息处理
|
||||
- **临时调试**:验证后删除(如调试日志、测试截图)
|
||||
- **有价值信息**:保留(如错误日志、性能数据)
|
||||
### 调试信息处理
|
||||
- **临时调试**:验证后删除(如调试日志、测试截图)
|
||||
- **有价值信息**:保留(如错误日志、性能数据)
|
||||
|
||||
### 敏感信息处理
|
||||
- **禁止落盘**:Cookie 值、Token、密钥、完整手机号、支付凭证。
|
||||
- **日志引用**:仅记录必要关键词与结论,避免粘贴大段原始日志。
|
||||
- **路径引用**:优先给相对路径与文件名,不记录无关个人目录信息。
|
||||
|
||||
### 状态标记更新
|
||||
- **🔄 待验证** → 验证后更新为 **✅ 已修复** 或 **❌ 失败**
|
||||
@@ -279,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-08
|
||||
**最后更新**:2026-03-03
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# 前端开发规范
|
||||
|
||||
## 文档定位
|
||||
|
||||
- 本文档只定义前端开发规范与约束(结构、交互、持久化、接口调用、Checklist)。
|
||||
- 功能说明与启动方式请查看 `Docs/FRONTEND_README.md`。
|
||||
- 历史变更请记录在 `Docs/DevLogs/` 与 `Docs/TASK_COMPLETE.md`,不要写入本规范文档。
|
||||
|
||||
## 目录结构
|
||||
|
||||
采用轻量 FSD(Feature-Sliced Design)结构:
|
||||
@@ -10,8 +16,9 @@ frontend/src/
|
||||
│ ├── page.tsx # 首页(视频生成)
|
||||
│ ├── publish/ # 发布管理页
|
||||
│ ├── admin/ # 管理员页面
|
||||
│ ├── login/ # 登录
|
||||
│ └── register/ # 注册
|
||||
│ ├── login/ # 登录
|
||||
│ ├── register/ # 注册
|
||||
│ └── pay/ # 付费开通会员
|
||||
├── features/ # 功能模块(按业务拆分)
|
||||
│ ├── home/
|
||||
│ │ ├── model/ # 业务逻辑 hooks
|
||||
@@ -61,12 +68,15 @@ frontend/src/
|
||||
│ ├── hooks/
|
||||
│ │ ├── useTitleInput.ts
|
||||
│ │ └── usePublishPrefetch.ts
|
||||
│ ├── ui/
|
||||
│ │ └── SelectPopover.tsx # 统一下拉/BottomSheet 选择器
|
||||
│ ├── types/
|
||||
│ │ ├── user.ts # User 类型定义
|
||||
│ │ └── publish.ts # 发布相关类型
|
||||
│ └── contexts/ # 全局 Context(Auth、Task)
|
||||
│ └── contexts/ # 全局 Context(Auth、Task、Cleanup)
|
||||
│ ├── AuthContext.tsx
|
||||
│ └── TaskContext.tsx
|
||||
│ ├── TaskContext.tsx
|
||||
│ └── CleanupContext.tsx
|
||||
├── components/ # 遗留通用组件
|
||||
│ └── VideoPreviewModal.tsx
|
||||
└── proxy.ts # Next.js middleware(路由保护)
|
||||
@@ -150,6 +160,82 @@ body {
|
||||
| `sm:` | ≥ 640px | 平板/桌面 |
|
||||
| `lg:` | ≥ 1024px | 大屏桌面 |
|
||||
|
||||
### embedded 组件模式
|
||||
|
||||
合并板块时,子组件通过 `embedded?: boolean` prop 控制是否渲染外层卡片容器和主标题。
|
||||
|
||||
```tsx
|
||||
// embedded=false(独立使用):渲染完整卡片
|
||||
<div className="bg-white/5 rounded-2xl p-6 border border-white/10">
|
||||
<h2>标题</h2>
|
||||
{content}
|
||||
</div>
|
||||
|
||||
// embedded=true(嵌入父卡片):只渲染内容
|
||||
{content}
|
||||
```
|
||||
|
||||
- 子标题使用 `<h3 className="text-sm font-medium text-gray-400">`
|
||||
- 分隔线使用 `<div className="border-t border-white/10 my-4" />`
|
||||
- 移动端标题行避免 `whitespace-nowrap`,长描述文字可用 `hidden sm:inline` 在移动端隐藏
|
||||
|
||||
### 按钮视觉层级
|
||||
|
||||
| 层级 | 样式 | 用途 |
|
||||
|------|------|------|
|
||||
| 主操作 | `px-4 py-2 text-sm font-medium bg-gradient-to-r from-purple-600 to-pink-600 shadow-sm` | 生成配音、立即发布 |
|
||||
| 辅助操作 | `px-2 py-1 text-xs bg-white/10 rounded` | 刷新、上传、语速 |
|
||||
| 触屏可见 | `opacity-40 group-hover:opacity-100` | 列表行内操作(编辑/删除) |
|
||||
|
||||
---
|
||||
|
||||
## 统一下拉选择器规范 (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]`)
|
||||
|
||||
---
|
||||
|
||||
## 发布后清理弹窗规范 (CleanupContext)
|
||||
|
||||
发布页由 `CleanupContext` 统一承接“全部平台发布成功后的清理引导”,规则如下:
|
||||
|
||||
- 触发条件:仅当本次发布结果 **全部成功** 才触发弹窗;有任一失败则走原内联结果展示。
|
||||
- 持久化恢复:`cleanup_pending` 写入 localStorage,支持刷新/跳转后恢复;带 `createdAt`,24 小时自动过期。
|
||||
- 清理顺序:必须先调用 `POST /api/videos/cleanup`;仅在接口成功后才清本地输入字段并关闭弹窗。
|
||||
- 状态同步:清理成功后派发 `vigent:workspace-cleared` 事件,当前发布页输入态需就地重置(避免“localStorage 已清空但页面仍显示旧值”)。
|
||||
- 失败处理:接口失败时保留弹窗和输入数据,允许重试;连续失败达到阈值后显示“暂不清理,继续使用”。
|
||||
- 本地清理范围:仅输入内容(文案/标题/副标题/发布标题/标签),不清用户偏好(样式、字号、边距、模型、BGM 等)。
|
||||
- 下载策略:弹窗“下载视频备份”必须使用同源下载接口(`/api/videos/generated/{id}/download`),不要直接使用签名 URL 作为 `href`。
|
||||
|
||||
---
|
||||
|
||||
## API 请求规范
|
||||
@@ -258,9 +344,35 @@ import { formatDate } from '@/shared/lib/media';
|
||||
|
||||
### 刷新回顶部(统一体验)
|
||||
|
||||
- 长页面(如首页/发布页)在首次挂载时统一回到顶部,避免浏览器恢复旧滚动位置导致进入即跳到中部。
|
||||
- 推荐实现:`useEffect(() => { window.scrollTo({ top: 0, left: 0, behavior: 'auto' }); }, [])`
|
||||
- 列表内自动定位(素材/历史记录)应跳过恢复后的首次触发,防止刷新后页面二次跳动。
|
||||
- 长页面(如首页/发布页)在首次挂载时统一回到顶部。
|
||||
- **必须**在页面级 `useEffect` 中设置 `history.scrollRestoration = "manual"` 禁用浏览器原生滚动恢复。
|
||||
- 调用 `window.scrollTo({ top: 0, left: 0, behavior: "auto" })` 并追加 200ms 延迟兜底(防止异步 effect 覆盖)。
|
||||
- **列表自动滚动必须使用时间门控**:页面加载后 1 秒内禁止所有列表自动滚动效果(`scrollEffectsEnabled` ref),防止持久化恢复 + 异步数据加载触发 `scrollIntoView` 导致页面跳动。
|
||||
- 推荐模式:
|
||||
|
||||
```typescript
|
||||
// 页面级(HomePage / PublishPage)
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
if ("scrollRestoration" in history) history.scrollRestoration = "manual";
|
||||
window.scrollTo({ top: 0, left: 0, behavior: "auto" });
|
||||
const timer = setTimeout(() => window.scrollTo({ top: 0, left: 0, behavior: "auto" }), 200);
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
// Controller 级(列表滚动时间门控)
|
||||
const scrollEffectsEnabled = useRef(false);
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => { scrollEffectsEnabled.current = true; }, 1000);
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
// 列表滚动 effect(BGM/素材/视频等)
|
||||
useEffect(() => {
|
||||
if (!selectedId || !scrollEffectsEnabled.current) return;
|
||||
target?.scrollIntoView({ block: "nearest", behavior: "smooth" });
|
||||
}, [selectedId, list]);
|
||||
```
|
||||
|
||||
### 路由预取
|
||||
|
||||
@@ -292,8 +404,9 @@ import { formatDate } from '@/shared/lib/media';
|
||||
- `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)
|
||||
- `shared/contexts`:全局 Context(AuthContext / TaskContext / CleanupContext)
|
||||
- `components/`:遗留通用组件(VideoPreviewModal)
|
||||
|
||||
## 类型定义规范
|
||||
@@ -312,11 +425,14 @@ import { formatDate } from '@/shared/lib/media';
|
||||
- 标题样式 ID / 字幕样式 ID
|
||||
- 标题字号 / 字幕字号
|
||||
- 标题显示模式(`short` / `persistent`)
|
||||
- 背景音乐选择 / 音量 / 开关状态
|
||||
- 唇形模型模式(`default` / `fast` / `advanced`)
|
||||
- 背景音乐选择 / 开关状态(当前前端不提供音量滑杆,生成时使用固定音量)
|
||||
- 输出画面比例(`9:16` / `16:9`)
|
||||
- 素材选择 / 历史作品选择
|
||||
- 选中配音 ID (`selectedAudioId`)
|
||||
- 选中参考音频 ID (`selectedRefAudio` 对应 id)
|
||||
- 语速 (`speed`,声音克隆模式)
|
||||
- 语气 (`emotion`,声音克隆模式)
|
||||
- 时间轴段信息 (`useTimelineEditor` 的 localStorage)
|
||||
|
||||
### 历史文案(独立持久化)
|
||||
@@ -352,6 +468,7 @@ import { formatDate } from '@/shared/lib/media';
|
||||
- 发布按钮在未选择任何平台时禁用
|
||||
- 仅保留"立即发布",不再提供定时发布 UI/参数
|
||||
- **作品选择持久化**:使用 `video.id`(稳定标识)而非 `video.path`(签名 URL)进行选择、比较和 localStorage 存储。发布时根据 `id` 查找对应 `path` 发送请求。
|
||||
- **新作品优先级**:检测到“刚生成的新视频”时,页面首次恢复优先选中最新视频;之后用户手动改选会继续按持久化值恢复。
|
||||
|
||||
---
|
||||
|
||||
@@ -403,6 +520,10 @@ await api.post('/api/videos/generate', {
|
||||
|
||||
使用 `MediaRecorder` API 录制音频,格式为 `audio/webm`,上传后后端自动转换为 WAV (16kHz mono)。
|
||||
|
||||
- 录音入口放在“我的参考音频”区域底部右侧(与“上传音频”并排)。
|
||||
- 录音交互使用弹窗:开始/停止 -> 试听 -> 使用此录音 / 弃用本次录音。
|
||||
- 关闭录音弹窗时如仍在录制,会先停止录音再关闭。
|
||||
|
||||
```typescript
|
||||
// 录音需要用户授权麦克风
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
@@ -418,5 +539,5 @@ const mediaRecorder = new MediaRecorder(stream, { mimeType: 'audio/webm' });
|
||||
### UI 结构
|
||||
|
||||
配音方式使用 Tab 切换:
|
||||
- **EdgeTTS 音色** - 预设音色 2x3 网格
|
||||
- **声音克隆** - 参考音频列表 + 在线录音 + 语速下拉菜单 (5 档: 较慢/稍慢/正常/稍快/较快)
|
||||
- **EdgeTTS 音色** - 统一下拉选择(显示“音色名 + 语言”)
|
||||
- **声音克隆** - 参考音频选择器(含试听/重命名/删除/重识别)+ 底部右侧上传/录音入口(录音弹窗)+ 语速/语气下拉
|
||||
|
||||
@@ -2,46 +2,58 @@
|
||||
|
||||
ViGent2 的前端界面,采用 Next.js 16 + TailwindCSS 构建。
|
||||
|
||||
## 📌 文档定位
|
||||
|
||||
- 本文档用于说明前端功能、运行方式与目录概览(面向使用与协作)。
|
||||
- 开发规范与实现约束请查看 `Docs/FRONTEND_DEV.md`。
|
||||
- 历史变更与里程碑请查看 `Docs/DevLogs/` 与 `Docs/TASK_COMPLETE.md`。
|
||||
|
||||
## ✨ 核心功能
|
||||
|
||||
### 1. 视频生成 (`/`)
|
||||
- **素材管理**: 拖拽上传人物视频,实时预览。
|
||||
- **素材重命名**: 支持在列表中直接重命名素材。
|
||||
- **文案配音**: 集成 EdgeTTS,支持多音色选择 (云溪 / 晓晓)。
|
||||
- **AI 标题/标签**: 一键生成视频标题与标签 (Day 14)。
|
||||
- **标题/字幕样式**: 样式选择 + 预览 + 字号调节 (Day 16)。
|
||||
- **背景音乐**: 试听 + 音量控制 + 选择持久化 (Day 16)。
|
||||
- **交互优化**: 选择项持久化、列表内定位、刷新回顶部 (Day 16)。
|
||||
- **预览一致性**: 标题/字幕预览按素材分辨率缩放,效果更接近成片 (Day 17)。
|
||||
- **一、文案提取与编辑**: 文案输入/提取/翻译/保存;输入框右下角支持一键扩展到大编辑器。
|
||||
- **二、配音**: 配音方式(EdgeTTS/声音克隆)+ 配音列表(生成/试听/管理)合并为一个板块。
|
||||
- **三、素材编辑**: 视频素材(上传/选择/管理)+ 时间轴编辑(波形/色块/拖拽排序)合并为一个板块。
|
||||
- **四、标题与字幕**: 片头标题/副标题/字幕样式配置;短暂显示/常驻显示;样式预览使用视频片头帧作为真实背景。
|
||||
- **五、背景音乐**: 试听 + 搜索选择 + 选择持久化(无音量滑杆,生成时固定混音系数)。
|
||||
- **六、作品**(右栏): 作品列表 + 作品预览合并为一个板块。
|
||||
- **进度追踪**: 实时显示视频生成进度 (10% -> 100%)。
|
||||
- **作品预览**: 生成完成后直接播放下载(作品预览 + 历史作品)。
|
||||
- **下载直达**: 首页作品下载与发布成功弹窗下载统一走同源下载接口(`/api/videos/generated/{id}/download`),避免新标签页在线播放。
|
||||
- **预览优化**: 预览视频 `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 / 相对路径均可直接预览。
|
||||
- **发布方式**: 仅支持 "立即发布"。
|
||||
- **发布成功清理弹窗**: 全平台发布成功后触发 `CleanupModal`(展示成功平台、截图、下载备份、清理按钮),刷新/跳转后可恢复。
|
||||
- **清理失败兜底**: 清理接口失败时弹窗不关闭且不清本地输入;连续失败达到阈值后可“暂不清理,继续使用”。
|
||||
- **清理范围**: 仅清理输入内容字段(文案/标题/副标题/发布标题/标签),保留样式、字号、边距、模型等用户偏好。
|
||||
|
||||
### 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)。
|
||||
- **多语言支持**: EdgeTTS 10 语言声音列表,声音克隆 language 透传 (Day 22)。
|
||||
- **语速控制**: 声音克隆模式下支持 5 档语速 (0.8-1.2),统一下拉,选择持久化。
|
||||
- **语气控制**: 声音克隆模式下支持 4 种语气 (正常/欢快/低沉/严肃),统一下拉,选择持久化。
|
||||
- **多语言支持**: EdgeTTS 10 语言声音列表,声音克隆 language 透传。
|
||||
|
||||
### 4. 配音前置 + 时间轴编排 [Day 23 新增]
|
||||
### 4. 配音前置 + 时间轴编排
|
||||
- **配音独立生成**: 先生成配音 → 选中配音 → 再选素材 → 生成视频。
|
||||
- **配音管理面板**: 生成/试听/改名/删除/选中,异步生成 + 进度轮询。
|
||||
- **时间轴编辑器**: wavesurfer.js 音频波形 + 色块可视化素材分配,拖拽分割线调整各段时长。
|
||||
@@ -51,27 +63,37 @@ ViGent2 的前端界面,采用 Next.js 16 + TailwindCSS 构建。
|
||||
- **时间轴语义对齐**: 超出音频时仅保留可见段并截齐末段,超出段不参与生成;不足音频时最后可见段自动循环补齐。
|
||||
- **画面比例控制**: 时间轴顶部支持 `9:16 / 16:9` 输出比例选择,设置持久化并透传后端。
|
||||
|
||||
### 5. 字幕与标题 [Day 13 新增]
|
||||
- **片头标题**: 可选输入,限制 15 字;支持“短暂显示 / 常驻显示”,默认短暂显示(4 秒)。
|
||||
### 5. 字幕与标题
|
||||
- **片头标题**: 可选输入,限制 15 字;支持”短暂显示 / 常驻显示”,默认短暂显示(4 秒);`常驻显示` 时主标题与副标题都会全程显示。
|
||||
- **片头副标题**: 可选输入,限制 20 字;显示在主标题下方,用于补充说明或悬念引导;独立样式配置(字体/字号/颜色/间距),可由 AI 同时生成;与标题共享显示模式设定;仅在视频画面中显示,不参与发布标题。
|
||||
- **标题同步**: 首页片头标题修改会同步到发布信息标题。
|
||||
- **逐字高亮字幕**: 卡拉OK效果,默认开启,可关闭。
|
||||
- **逐字高亮字幕**: 卡拉OK效果,默认开启。
|
||||
- **自动对齐**: 基于 faster-whisper 生成字级别时间戳。
|
||||
- **样式预设**: 标题/字幕样式选择 + 预览 + 字号调节 (Day 16)。
|
||||
- **默认样式**: 标题 90px 站酷快乐体;字幕 60px 经典黄字 + DingTalkJinBuTi (Day 17)。
|
||||
- **样式持久化**: 标题/字幕样式与字号刷新保留 (Day 17)。
|
||||
- **样式预设**: 标题/字幕/副标题样式选择 + 预览 + 字号调节。
|
||||
- **默认样式**: 标题 90px 站酷快乐体;字幕 60px 经典黄字 + DingTalkJinBuTi。
|
||||
- **样式持久化**: 标题/字幕/副标题样式与字号刷新保留。
|
||||
|
||||
### 6. 背景音乐 [Day 16 新增]
|
||||
- **试听预览**: 点击试听即选中,音量滑块实时生效。
|
||||
- **混音控制**: 仅影响 BGM,配音保持原音量。
|
||||
### 6. 背景音乐
|
||||
- **试听预览**: 下拉列表内可直接试听。
|
||||
- **选择体验**: 发布页同款搜索选择器,打开时自动定位到当前已选。
|
||||
- **混音控制**: 当前前端不提供音量滑杆,生成时固定 `bgm_volume=0.2`,保持配音音量稳定。
|
||||
|
||||
### 7. 账户设置 [Day 15 新增]
|
||||
### 7. 账户设置
|
||||
- **手机号登录**: 11位中国手机号验证登录。
|
||||
- **账户下拉菜单**: 显示有效期 + 修改密码 + 安全退出。
|
||||
- **账户下拉菜单**: 显示手机号(中间四位脱敏)+ 有效期 + 修改密码 + 安全退出。
|
||||
- **修改密码**: 弹窗输入当前密码与新密码,修改后强制重新登录。
|
||||
- **登录即时生效**: 登录成功后 AuthContext 立即写入用户数据,无需刷新即显示手机号。
|
||||
|
||||
### 8. 文案提取助手 (`ScriptExtractionModal`) [Day 15 新增]
|
||||
### 8. 付费开通会员 (`/pay`)
|
||||
- **支付宝电脑网站支付**: 跳转支付宝官方收银台,支持扫码/账号登录/余额等多种支付方式。
|
||||
- **自动激活**: 支付成功后异步回调自动激活会员(有效期 1 年),前端轮询检测支付结果。
|
||||
- **到期续费**: 会员到期后登录自动跳转付费页续费,流程与首次开通一致。
|
||||
- **管理员激活**: 管理员手动激活功能并存,两种方式互不影响。
|
||||
|
||||
### 9. 文案提取助手 (`ScriptExtractionModal`)
|
||||
- **多源提取**: 支持文件拖拽上传与 URL 粘贴 (B站/抖音/TikTok)。
|
||||
- **AI 洗稿**: 集成 GLM-4.7-Flash,自动改写为口播文案。
|
||||
- **AI 智能改写**: 集成 GLM-4.7-Flash,自动改写为口播文案。
|
||||
- **自定义提示词**: 可自定义改写提示词,留空使用默认;设置持久化到 localStorage。
|
||||
- **一键填入**: 提取结果直接填充至视频生成输入框。
|
||||
- **智能交互**: 实时进度展示,防误触设计。
|
||||
|
||||
@@ -84,7 +106,7 @@ ViGent2 的前端界面,采用 Next.js 16 + TailwindCSS 构建。
|
||||
- **音频波形**: wavesurfer.js (时间轴编辑器)
|
||||
- **API**: Axios 实例 `@/shared/api/axios` (对接后端 FastAPI :8006)
|
||||
|
||||
## 🚀 开发指南
|
||||
## 🚀 快速开始
|
||||
|
||||
### 安装依赖
|
||||
|
||||
@@ -109,6 +131,8 @@ src/
|
||||
│ ├── page.tsx # 视频生成主页
|
||||
│ ├── publish/ # 发布管理页
|
||||
│ │ └── page.tsx
|
||||
│ ├── pay/ # 付费开通会员页
|
||||
│ │ └── page.tsx
|
||||
│ └── layout.tsx # 全局布局 (导航栏)
|
||||
├── features/
|
||||
│ ├── home/
|
||||
@@ -130,8 +154,10 @@ src/
|
||||
- **URL 统一工具**: `@/shared/lib/media` 提供 `resolveMediaUrl` / `resolveAssetUrl`
|
||||
- **代理配置**: Next.js Rewrites (如需) 或直接 CORS。
|
||||
|
||||
## 🎨 设计规范
|
||||
## 🎨 UI 说明(概览)
|
||||
|
||||
- **主色调**: 深紫/黑色系 (Dark Mode)
|
||||
- **交互**: 悬停微动画 (Hover Effects)
|
||||
- **响应式**: 适配桌面端大屏操作
|
||||
- 业务选择器统一使用 `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`。
|
||||
|
||||
---
|
||||
|
||||
## 参考链接
|
||||
|
||||
285
Docs/MUSETALK_DEPLOY.md
Normal file
285
Docs/MUSETALK_DEPLOY.md
Normal file
@@ -0,0 +1,285 @@
|
||||
# MuseTalk 部署指南
|
||||
|
||||
> **更新时间**:2026-03-02
|
||||
> **适用版本**:MuseTalk v1.5 (常驻服务模式)
|
||||
> **架构**:FastAPI 常驻服务 + PM2 进程管理
|
||||
|
||||
---
|
||||
|
||||
## 架构概览
|
||||
|
||||
MuseTalk 作为 **混合唇形同步方案** 的长视频引擎:
|
||||
|
||||
- **短视频 (<100s,按当前 `.env` 示例)** → LatentSync 1.6 (GPU1, 端口 8007)
|
||||
- **长视频 (>=100s,按当前 `.env` 示例)** → MuseTalk 1.5 (GPU0, 端口 8011)
|
||||
- 路由阈值由 `LIPSYNC_DURATION_THRESHOLD` 控制
|
||||
- MuseTalk 不可用时自动回退到 LatentSync
|
||||
|
||||
---
|
||||
|
||||
## 硬件要求
|
||||
|
||||
| 配置 | 最低要求 | 推荐配置 |
|
||||
|------|----------|----------|
|
||||
| GPU | 8GB VRAM (RTX 3060) | 24GB VRAM (RTX 3090) |
|
||||
| 内存 | 32GB | 64GB |
|
||||
| CUDA | 11.7+ | 11.8 |
|
||||
|
||||
> MuseTalk fp16 推理约需 4-8GB 显存,可与 CosyVoice 共享 GPU0。
|
||||
|
||||
---
|
||||
|
||||
## 安装步骤
|
||||
|
||||
### 1. Conda 环境
|
||||
|
||||
```bash
|
||||
cd /home/rongye/ProgramFiles/ViGent2/models/MuseTalk
|
||||
conda create -n musetalk python=3.10 -y
|
||||
conda activate musetalk
|
||||
```
|
||||
|
||||
### 2. PyTorch 2.0.1 + CUDA 11.8
|
||||
|
||||
> 必须使用此版本,mmcv 预编译包依赖。
|
||||
|
||||
```bash
|
||||
pip install torch==2.0.1 torchvision==0.15.2 torchaudio==2.0.2 --index-url https://download.pytorch.org/whl/cu118
|
||||
```
|
||||
|
||||
### 3. 依赖安装
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
|
||||
# MMLab 系列
|
||||
pip install --no-cache-dir -U openmim
|
||||
mim install mmengine
|
||||
mim install "mmcv==2.0.1"
|
||||
mim install "mmdet==3.1.0"
|
||||
pip install chumpy --no-build-isolation
|
||||
pip install "mmpose==1.1.0" --no-deps
|
||||
|
||||
# FastAPI 服务依赖
|
||||
pip install fastapi uvicorn httpx
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 模型权重
|
||||
|
||||
### 目录结构
|
||||
|
||||
```
|
||||
models/MuseTalk/models/
|
||||
├── musetalk/ ← v1 基础模型
|
||||
│ ├── config.json -> musetalk.json (软链接)
|
||||
│ ├── musetalk.json
|
||||
│ ├── musetalkV15 -> ../musetalkV15 (软链接, 关键!)
|
||||
│ └── pytorch_model.bin (~3.2GB)
|
||||
├── musetalkV15/ ← v1.5 UNet 模型
|
||||
│ ├── musetalk.json
|
||||
│ └── unet.pth (~3.2GB)
|
||||
├── sd-vae/ ← Stable Diffusion VAE
|
||||
│ ├── config.json
|
||||
│ └── diffusion_pytorch_model.bin
|
||||
├── whisper/ ← OpenAI Whisper Tiny
|
||||
│ ├── config.json
|
||||
│ ├── pytorch_model.bin (~151MB)
|
||||
│ └── preprocessor_config.json
|
||||
├── dwpose/ ← DWPose 人体姿态检测
|
||||
│ └── dw-ll_ucoco_384.pth (~387MB)
|
||||
├── syncnet/ ← SyncNet 唇形同步评估
|
||||
│ └── latentsync_syncnet.pt
|
||||
└── face-parse-bisent/ ← 人脸解析模型
|
||||
├── 79999_iter.pth (~53MB)
|
||||
└── resnet18-5c106cde.pth (~45MB)
|
||||
```
|
||||
|
||||
### 下载方式
|
||||
|
||||
使用项目自带脚本:
|
||||
|
||||
```bash
|
||||
cd /home/rongye/ProgramFiles/ViGent2/models/MuseTalk
|
||||
conda activate musetalk
|
||||
bash download_weights.sh
|
||||
```
|
||||
|
||||
或手动 Python API 下载:
|
||||
|
||||
```bash
|
||||
conda activate musetalk
|
||||
export HF_ENDPOINT=https://hf-mirror.com
|
||||
python -c "
|
||||
from huggingface_hub import snapshot_download
|
||||
snapshot_download('TMElyralab/MuseTalk', local_dir='models',
|
||||
allow_patterns=['musetalk/*', 'musetalkV15/*'])
|
||||
snapshot_download('stabilityai/sd-vae-ft-mse', local_dir='models/sd-vae',
|
||||
allow_patterns=['config.json', 'diffusion_pytorch_model.bin'])
|
||||
snapshot_download('openai/whisper-tiny', local_dir='models/whisper',
|
||||
allow_patterns=['config.json', 'pytorch_model.bin', 'preprocessor_config.json'])
|
||||
snapshot_download('yzd-v/DWPose', local_dir='models/dwpose',
|
||||
allow_patterns=['dw-ll_ucoco_384.pth'])
|
||||
"
|
||||
```
|
||||
|
||||
### 创建必要的软链接
|
||||
|
||||
```bash
|
||||
cd /home/rongye/ProgramFiles/ViGent2/models/MuseTalk/models/musetalk
|
||||
ln -sf musetalk.json config.json
|
||||
ln -sf ../musetalkV15 musetalkV15
|
||||
```
|
||||
|
||||
> **关键**:`musetalk/musetalkV15` 软链接缺失会导致权重检测失败 (`weights: False`)。
|
||||
|
||||
---
|
||||
|
||||
## 服务启动
|
||||
|
||||
### PM2 进程管理(推荐)
|
||||
|
||||
```bash
|
||||
# 首次注册
|
||||
cd /home/rongye/ProgramFiles/ViGent2
|
||||
pm2 start run_musetalk.sh --name vigent2-musetalk
|
||||
pm2 save
|
||||
|
||||
# 日常管理
|
||||
pm2 restart vigent2-musetalk
|
||||
pm2 logs vigent2-musetalk
|
||||
pm2 stop vigent2-musetalk
|
||||
```
|
||||
|
||||
### 手动启动
|
||||
|
||||
```bash
|
||||
cd /home/rongye/ProgramFiles/ViGent2/models/MuseTalk
|
||||
/home/rongye/ProgramFiles/miniconda3/envs/musetalk/bin/python scripts/server.py
|
||||
```
|
||||
|
||||
### 健康检查
|
||||
|
||||
```bash
|
||||
curl http://localhost:8011/health
|
||||
# {"status":"ok","model_loaded":true}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 后端配置
|
||||
|
||||
`backend/.env` 中的相关变量:
|
||||
|
||||
```ini
|
||||
# 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=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`
|
||||
|
||||
---
|
||||
|
||||
## 相关文件
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `models/MuseTalk/scripts/server.py` | FastAPI 常驻服务 (端口 8011) |
|
||||
| `run_musetalk.sh` | PM2 启动脚本 |
|
||||
| `backend/app/services/lipsync_service.py` | 混合路由 + `_call_musetalk_server()` |
|
||||
| `backend/app/core/config.py` | `MUSETALK_*` 配置项 |
|
||||
|
||||
---
|
||||
|
||||
## 性能优化 (server.py v2)
|
||||
|
||||
首次长视频测试 (136s, 3404 帧) 耗时 30 分钟。分析发现瓶颈在人脸检测 (28%)、BiSeNet 合成 (22%)、I/O (17%),而非 UNet 推理 (17%)。
|
||||
|
||||
### 已实施优化
|
||||
|
||||
| 优化项 | 说明 |
|
||||
|--------|------|
|
||||
| `MUSETALK_BATCH_SIZE` 8→32 | RTX 3090 显存充裕,UNet 推理加速 ~3x |
|
||||
| cv2.VideoCapture 直读帧 | 跳过 ffmpeg→PNG→imread 链路 |
|
||||
| 人脸检测降频 (每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 次。
|
||||
|
||||
### 调优参数
|
||||
|
||||
所有参数通过 `backend/.env` 配置(修改后需重启 MuseTalk 服务生效):
|
||||
|
||||
```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=平衡)
|
||||
```
|
||||
|
||||
> 对于口播视频 (人脸几乎不动),detect_every=5 的插值误差可忽略。
|
||||
> 如人脸运动剧烈或追求最佳质量,使用 detect_every=2。
|
||||
|
||||
---
|
||||
|
||||
## 常见问题
|
||||
|
||||
### huggingface-hub 版本冲突
|
||||
|
||||
```
|
||||
ImportError: huggingface-hub>=0.19.3,<1.0 is required
|
||||
```
|
||||
|
||||
**解决**:降级 huggingface-hub
|
||||
|
||||
```bash
|
||||
pip install "huggingface-hub>=0.19.3,<1.0"
|
||||
```
|
||||
|
||||
### mmcv 导入失败
|
||||
|
||||
```bash
|
||||
pip uninstall mmcv mmcv-full -y
|
||||
mim install "mmcv==2.0.1"
|
||||
```
|
||||
|
||||
### 音视频长度不匹配
|
||||
|
||||
已在 `musetalk/utils/audio_processor.py` 中修复(零填充逻辑),无需额外处理。
|
||||
215
Docs/PUBLISH_DEPLOY.md
Normal file
215
Docs/PUBLISH_DEPLOY.md
Normal file
@@ -0,0 +1,215 @@
|
||||
# 多平台发布部署与实现说明(抖音 / 微信视频号 / 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}`:读取发布成功截图(需登录)
|
||||
- `POST /api/videos/cleanup`:清理当前用户工作区生成产物(发布成功后前端触发)
|
||||
|
||||
核心路由文件:`backend/app/modules/publish/router.py`。
|
||||
|
||||
### 2.2 服务分层
|
||||
|
||||
- `PublishService`:平台路由、账号隔离、视频路径处理、调用具体 uploader
|
||||
- `QRLoginService`:Playwright 获取二维码、监控扫码结果、保存 Cookie
|
||||
- `*Uploader`:平台发布自动化(抖音/微信/小红书基于 Playwright,B站基于 biliup)
|
||||
|
||||
### 2.3 发布成功后的清理联动
|
||||
|
||||
- 前端 `CleanupContext` 在“本次所选平台全部发布成功”时触发清理弹窗。
|
||||
- 用户点击清理时先调用 `POST /api/videos/cleanup`,仅接口成功后才清本地输入并关闭弹窗。
|
||||
- 清理成功后前端派发 `vigent:workspace-cleared` 事件,当前发布页会就地重置标题/标签输入态。
|
||||
- 接口失败时弹窗保持打开并允许重试;连续失败达到阈值后可“暂不清理,继续使用”。
|
||||
- 弹窗“下载视频备份”走同源下载接口:`GET /api/videos/generated/{video_id}/download`,确保浏览器直接保存文件而非新标签页播放。
|
||||
|
||||
---
|
||||
|
||||
## 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`。
|
||||
> 本文档仅保留用于回溯旧方案,不建议新部署继续使用。
|
||||
|
||||
## 系统要求
|
||||
|
||||
|
||||
@@ -16,14 +16,16 @@
|
||||
文本 → EdgeTTS → 音频 → LatentSync → FFmpeg合成 → 最终视频
|
||||
|
||||
新流程 (单素材):
|
||||
文本 → EdgeTTS/Qwen3-TTS/预生成配音 → 音频 ─┬→ LatentSync → 唇形视频 ─┐
|
||||
文本 → EdgeTTS/CosyVoice/预生成配音 → 音频 ─┬→ LatentSync/MuseTalk → 唇形视频 ─┐
|
||||
└→ faster-whisper → 字幕JSON ─┴→ Remotion合成 → 最终视频
|
||||
|
||||
新流程 (多素材):
|
||||
音频 → 多素材按 custom_assignments 拼接 → LatentSync (单次推理) → 唇形视频 ─┐
|
||||
音频 → 多素材按 custom_assignments 拼接 → LatentSync/MuseTalk (单次推理) → 唇形视频 ─┐
|
||||
音频 → faster-whisper → 字幕JSON ─────────────────────────────────────────────┴→ Remotion合成 → 最终视频
|
||||
```
|
||||
|
||||
> **唇形同步路由**: 短视频 (<100s,按当前 `.env` 示例) 用 LatentSync 1.6 (GPU1),长视频 (>=100s,按当前 `.env` 示例) 用 MuseTalk 1.5 (GPU0),由 `LIPSYNC_DURATION_THRESHOLD` 控制。
|
||||
|
||||
## 系统要求
|
||||
|
||||
| 组件 | 要求 |
|
||||
@@ -144,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 |
|
||||
@@ -185,6 +187,7 @@ Remotion 渲染参数在 `backend/app/services/remotion_service.py` 中配置:
|
||||
| 参数 | 默认值 | 说明 |
|
||||
|------|--------|------|
|
||||
| `fps` | 25 | 输出帧率 |
|
||||
| `concurrency` | 4 | Remotion 并发渲染进程数(默认 4,可通过 `--concurrency` CLI 参数覆盖) |
|
||||
| `title_display_mode` | `short` | 标题显示模式(`short`=短暂显示;`persistent`=常驻显示) |
|
||||
| `title_duration` | 4.0 | 标题显示时长(秒,仅 `short` 模式生效) |
|
||||
|
||||
@@ -238,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 渲染失败
|
||||
|
||||
查看后端日志:
|
||||
@@ -273,7 +285,7 @@ wget https://github.com/googlefonts/noto-cjk/raw/main/Sans/OTF/SimplifiedChinese
|
||||
|
||||
### 使用 GPU 0
|
||||
|
||||
faster-whisper 默认使用 GPU 0,与 LatentSync (GPU 1) 分开,避免显存冲突。如需指定 GPU:
|
||||
faster-whisper 默认使用 GPU 0,与 MuseTalk 共享 GPU 0;LatentSync 使用 GPU 1,互不冲突。如需指定 GPU:
|
||||
|
||||
```python
|
||||
# 在 whisper_service.py 中修改
|
||||
@@ -289,3 +301,8 @@ WhisperService(device="cuda:0") # 或 "cuda:1"
|
||||
| 2026-01-29 | 1.0.0 | 初始版本,使用 faster-whisper + Remotion 实现逐字高亮字幕和片头标题 |
|
||||
| 2026-02-10 | 1.1.0 | 更新架构图:多素材 concat-then-infer、预生成配音选项 |
|
||||
| 2026-01-30 | 1.0.1 | 字幕高亮样式与标题动画优化,视觉表现更清晰 |
|
||||
| 2026-02-25 | 1.2.0 | 字幕时间戳从线性插值改为 Whisper 节奏映射,修复长视频字幕漂移 |
|
||||
| 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 24 - 鉴权到期治理 + 多素材时间轴稳定性修复)
|
||||
**更新时间**: 2026-02-11
|
||||
**进度**: 100% (Day 32 - 视频下载同源修复 + 清理链路体验收敛)
|
||||
**更新时间**: 2026-03-04
|
||||
|
||||
---
|
||||
|
||||
@@ -10,17 +10,129 @@
|
||||
|
||||
> 这里记录了每一天的核心开发内容与 milestone。
|
||||
|
||||
### Day 24: 鉴权到期治理 + 多素材时间轴稳定性修复 (Current)
|
||||
- [x] **会员到期请求时失效**: 登录与鉴权接口统一执行 `expires_at` 检查;到期后自动停用账号、清理 session,并返回“会员已到期,请续费”。
|
||||
- [x] **画面比例控制**: 时间轴新增 `9:16 / 16:9` 输出比例选择,前端持久化并透传后端,单素材/多素材统一按目标分辨率处理。
|
||||
- [x] **标题/字幕防溢出**: Remotion 与前端预览统一响应式缩放、自动换行、描边/字距/边距比例缩放,降低预览与成片差异。
|
||||
- [x] **标题显示模式**: 标题行新增“短暂显示/常驻显示”下拉;默认短暂显示(4 秒),用户选择持久化并透传至 Remotion 渲染链路。
|
||||
- [x] **MOV 方向归一化**: 新增旋转元数据解析与 orientation normalize,修复“编码横屏+旋转元数据”导致的竖屏判断偏差。
|
||||
- [x] **多素材拼接稳定性**: 片段 prepare 与 concat 统一 25fps/CFR,concat 增加 `+genpts`,缓解段切换处“画面冻结口型还动”。
|
||||
- [x] **时间轴语义对齐**: 打通 `source_end` 全链路;修复 `sourceStart>0 且 sourceEnd=0` 时长计算;生成时以时间轴可见段 assignments 为准,超出段不参与。
|
||||
- [x] **交互细节优化**: 页面刷新回顶部;素材/历史列表首轮自动滚动抑制,减少恢复状态时页面跳动。
|
||||
### Day 32: 视频下载同源修复 + Day 日志拆分归档 (Current)
|
||||
- [x] **下载链路修复**: 新增 `GET /api/videos/generated/{video_id}/download`,统一返回 `Content-Disposition: attachment`,修复“点击下载却新开标签页播放”问题。
|
||||
- [x] **发布成功弹窗下载改造**: `CleanupContext` 从传 URL 改为传 `videoId`,下载按钮改走同源接口,去掉 `target="_blank"`。
|
||||
- [x] **首页下载改造**: `PreviewPanel` 同步切换到同源下载接口,首页与发布页下载行为一致。
|
||||
- [x] **兼容旧持久化状态**: `CleanupContext` 对旧 `videoDownloadUrl` 做 `videoId` 解析回填,避免旧 pending 状态失效。
|
||||
- [x] **文档拆分归档**: 将“下载修复开始后的今日内容”归档到 `Docs/DevLogs/Day32.md`,并从 `Day31.md` 移除对应章节与验证记录。
|
||||
|
||||
### Day 23: 配音前置重构 + 素材时间轴编排 + UI 体验优化 + 声音克隆增强
|
||||
### Day 31: 文档体系收敛 + 音色试听 + 录音弹窗重构 + 发布登录稳定性修复
|
||||
- [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`;修复扩展输入框打字后失焦问题,移除紫色聚焦边框。
|
||||
- [x] **站点图标更新**: 使用 `Temp/video.png` 替换网站 icon,生成并更新 `frontend/src/app/icon.png` 与多尺寸 `frontend/src/app/favicon.ico`。
|
||||
- [x] **发布后清理链路加固**: 新增/优化 `CleanupContext` + `/api/videos/cleanup` 全链路;后端删除异常不再吞错、清理接口严格成功语义;前端失败不清本地/不关弹窗,3 次失败可暂不清理,清理状态 24h 过期并支持用户切换复位;清理范围收敛为输入内容字段并保留用户偏好。
|
||||
|
||||
### 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 耗时从分钟级降到秒级。
|
||||
- [x] **FFmpeg 超时保护**: `_run_ffmpeg()` timeout=600, `_get_duration()` timeout=30。
|
||||
- [x] **全局并发限制**: `asyncio.Semaphore(2)` 控制同时运行的生成任务数。
|
||||
- [x] **Redis 任务 TTL**: create 24h, completed/failed 2h, list 自动清理过期索引。
|
||||
- [x] **临时字体清理**: 字体文件加入 temp_files 清理列表。
|
||||
- [x] **预览背景 CORS 修复**: 素材同源代理 `/api/materials/stream/{id}` 彻底绕开跨域。
|
||||
- [x] **CosyVoice 语气控制**: 声音克隆模式新增语气下拉(正常/欢快/低沉/严肃),基于 `inference_instruct2()` 自然语言指令控制情绪,全链路透传 instruct_text,默认"正常"行为不变。
|
||||
|
||||
### Day 28: CosyVoice FP16 加速 + 文档全面更新
|
||||
- [x] **CosyVoice FP16 半精度加速**: `AutoModel()` 开启 `fp16=True`,LLM 推理和 Flow Matching 自动混合精度运行,预估提速 30-40%、显存降低 ~30%。
|
||||
- [x] **文档全面更新**: README.md / DEPLOY_MANUAL.md / SUBTITLE_DEPLOY.md / BACKEND_README.md 补充 MuseTalk 混合唇形同步方案、性能优化、Remotion 并发渲染等内容。
|
||||
|
||||
### Day 27: Remotion 描边修复 + 字体样式扩展 + 混合唇形同步 + 性能优化
|
||||
- [x] **描边渲染修复**: 标题/副标题/字幕从 `textShadow` 4 方向模拟改为 CSS 原生 `-webkit-text-stroke` + `paint-order: stroke fill`,修复描边过粗和副标题重影问题。
|
||||
- [x] **字体样式扩展**: 标题样式 4→12 个(+庞门正道/优设标题圆/阿里数黑体/文道潮黑/无界黑/厚底黑/寒蝉半圆体/欣意吉祥宋),字幕样式 4→8 个(+少女粉/清新绿/金色隶书/楷体红字)。
|
||||
- [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),按音频时长自动路由(由 `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。
|
||||
|
||||
### Day 26: 前端优化:板块合并 + 序号标题 + UI 精细化
|
||||
- [x] **板块合并**: 首页 9 个独立板块合并为 5 个主板块(配音方式+配音列表→三、配音;视频素材+时间轴→四、素材编辑;历史作品+作品预览→六、作品)。
|
||||
- [x] **中文序号标题**: 一~十编号(首页一~六,发布页七~十),移除所有 emoji 图标。
|
||||
- [x] **embedded 模式**: 6 个组件支持 `embedded` prop,嵌入时不渲染外层卡片/标题。
|
||||
- [x] **配音列表两行布局**: embedded 模式第 1 行语速+生成配音(右对齐),第 2 行配音列表+刷新。
|
||||
- [x] **子组件自渲染子标题**: MaterialSelector/TimelineEditor embedded 时自渲染 h3 子标题+操作按钮同行。
|
||||
- [x] **下拉对齐**: TitleSubtitlePanel 标签统一 `w-20`,下拉 `w-1/3 min-w-[100px]`,垂直对齐。
|
||||
- [x] **参考音频文案简化**: 底部段落移至标题旁,简化为 `(上传3-10秒语音样本)`。
|
||||
- [x] **账户手机号显示**: AccountSettingsDropdown 新增手机号显示。
|
||||
- [x] **标题显示模式对副标题生效**: payload 条件修复 + UI 下拉上移至板块标题行。
|
||||
- [x] **登录后用户信息立即可用**: AuthContext 暴露 `setUser`,登录成功后立即写入用户数据,修复登录后显示"未知账户"的问题。
|
||||
- [x] **文案微调**: 素材描述改为"上传自拍视频,最多可选4个";显示模式选项加"标题"前缀。
|
||||
- [x] **UI/UX 体验优化**: 操作按钮移动端可见(opacity-40)、手机号脱敏、标题字数计数器、时间轴拖拽抓手图标、截取滑块放大。
|
||||
- [x] **代码质量修复**: 密码弹窗 success 清空、MaterialSelector useMemo + disabled 守卫、TimelineEditor useMemo。
|
||||
- [x] **发布页响应式布局**: 平台账号卡片单行布局,移动端紧凑(小图标/小按钮),桌面端宽松(与其他板块风格一致)。
|
||||
- [x] **移动端刷新回顶部**: `scrollRestoration = "manual"` + 列表 scroll 时间门控(`scrollEffectsEnabled` ref,1 秒内禁止自动滚动)+ 延迟兜底 `scrollTo(0,0)`。
|
||||
- [x] **移动端样式预览缩小**: FloatingStylePreview 移动端宽度缩至 160px,位置改为右下角,不遮挡样式调节控件。
|
||||
- [x] **列表滚动条统一隐藏**: 所有列表(BGM/配音/作品/素材/文案提取)滚动条改回 `hide-scrollbar`。
|
||||
- [x] **移动端配音/素材适配**: VoiceSelector 按钮移动端缩小(`px-2 sm:px-4`)修复克隆声音不可见;MaterialSelector 标题行移除 `whitespace-nowrap`,描述移动端隐藏,修复刷新按钮溢出。
|
||||
- [x] **生成配音按钮放大**: 从辅助尺寸(`text-xs px-2 py-1`)升级为主操作尺寸(`text-sm font-medium px-4 py-2`),新增阴影。
|
||||
- [x] **生成进度条位置调整**: 从"六、作品"卡片内部提取到右栏独立卡片,显示在作品卡片上方,更醒目。
|
||||
- [x] **LatentSync 超时修复**: httpx 超时从 1200s(20 分钟)改为 3600s(1 小时),修复 2 分钟以上视频口型推理超时回退问题。
|
||||
- [x] **字幕时间戳节奏映射**: `whisper_service.py` 从全程线性插值改为 Whisper 逐词节奏映射,修复长视频字幕漂移。
|
||||
|
||||
### Day 25: 文案提取修复 + 自定义提示词 + 片头副标题
|
||||
- [x] **抖音文案提取修复**: yt-dlp Fresh cookies 报错,重写 `_download_douyin_manual` 为移动端分享页 + 自动获取 ttwid 方案。
|
||||
- [x] **清理 DOUYIN_COOKIE**: 新方案不再需要手动维护 Cookie,从 `.env`/`config.py`/`service.py` 全面删除。
|
||||
- [x] **AI 智能改写自定义提示词**: 后端 `rewrite_script()` 支持 `custom_prompt` 参数;前端 checkbox 旁新增折叠式提示词编辑区,localStorage 持久化。
|
||||
- [x] **SSR 构建修复**: `useState` 初始化 `localStorage` 访问加 `typeof window` 守卫,修复 `npm run build` 报错。
|
||||
- [x] **片头副标题**: 新增 secondary_title(后端/Remotion/前端全链路),AI 同时生成,独立样式配置,20 字限制。
|
||||
- [x] **前端文案修正**: "AI 洗稿结果"→"AI 改写结果"。
|
||||
- [x] **yt-dlp 升级**: `2025.12.08` → `2026.2.21`。
|
||||
- [x] **参考音频中文文件名修复**: `sanitize_filename()` 将存储路径清洗为 ASCII 安全字符,纯中文名哈希兜底,原始名保留为展示名。
|
||||
|
||||
### Day 24: 鉴权到期治理 + 多素材时间轴稳定性修复
|
||||
- [x] **会员到期请求时失效**: 登录与鉴权接口统一执行 `expires_at` 检查;到期后自动停用账号、清理 session,并返回“会员已到期,请续费”。
|
||||
- [x] **画面比例控制**: 时间轴新增 `9:16 / 16:9` 输出比例选择,前端持久化并透传后端,单素材/多素材统一按目标分辨率处理。
|
||||
- [x] **标题/字幕防溢出**: Remotion 与前端预览统一响应式缩放、自动换行、描边/字距/边距比例缩放,降低预览与成片差异。
|
||||
- [x] **标题显示模式**: 标题行新增“短暂显示/常驻显示”下拉;默认短暂显示(4 秒),用户选择持久化并透传至 Remotion 渲染链路。
|
||||
- [x] **MOV 方向归一化**: 新增旋转元数据解析与 orientation normalize,修复“编码横屏+旋转元数据”导致的竖屏判断偏差。
|
||||
- [x] **多素材拼接稳定性**: 片段 prepare 与 concat 统一 25fps/CFR,concat 增加 `+genpts`,缓解段切换处“画面冻结口型还动”。
|
||||
- [x] **时间轴语义对齐**: 打通 `source_end` 全链路;修复 `sourceStart>0 且 sourceEnd=0` 时长计算;生成时以时间轴可见段 assignments 为准,超出段不参与。
|
||||
- [x] **交互细节优化**: 页面刷新回顶部;素材/历史列表首轮自动滚动抑制,减少恢复状态时页面跳动。
|
||||
|
||||
### Day 23: 配音前置重构 + 素材时间轴编排 + UI 体验优化 + 声音克隆增强
|
||||
|
||||
#### 第一阶段:配音前置
|
||||
- [x] **配音生成独立化**: 新增 `generated_audios` 后端模块(router/schemas/service),5 个 API 端点,复用现有 TTSService / voice_clone_service / task_store。
|
||||
@@ -210,9 +322,10 @@
|
||||
| **核心 API** | 100% | ✅ 稳定 |
|
||||
| **Web UI** | 100% | ✅ 稳定 (移动端适配) |
|
||||
| **唇形同步** | 100% | ✅ LatentSync 1.6 |
|
||||
| **TTS 配音** | 100% | ✅ EdgeTTS + CosyVoice 3.0 + 配音前置 + 时间轴编排 + 自动转写 + 语速控制 |
|
||||
| **TTS 配音** | 100% | ✅ EdgeTTS + CosyVoice 3.0 + 配音前置 + 时间轴编排 + 自动转写 + 语速控制 + 语气控制 |
|
||||
| **自动发布** | 100% | ✅ 抖音/微信视频号/B站/小红书 |
|
||||
| **用户认证** | 100% | ✅ 手机号 + JWT |
|
||||
| **付费会员** | 100% | ✅ 支付宝电脑网站支付 + 自动激活 |
|
||||
| **部署运维** | 100% | ✅ PM2 + Watchdog |
|
||||
|
||||
---
|
||||
|
||||
62
README.md
62
README.md
@@ -4,7 +4,7 @@
|
||||
|
||||
> 📹 **上传人物** · 🎙️ **输入文案** · 🎬 **一键成片**
|
||||
|
||||
基于 **LatentSync 1.6 + EdgeTTS** 的开源数字人口播视频生成系统。
|
||||
基于 **LatentSync 1.6 + MuseTalk 1.5 混合唇形同步** 的开源数字人口播视频生成系统。
|
||||
集成 **CosyVoice 3.0** 声音克隆与自动社交媒体发布功能。
|
||||
|
||||
[功能特性](#-功能特性) • [技术栈](#-技术栈) • [文档中心](#-文档中心) • [部署指南](Docs/DEPLOY_MANUAL.md)
|
||||
@@ -16,26 +16,31 @@
|
||||
## ✨ 功能特性
|
||||
|
||||
### 核心能力
|
||||
- 🎬 **高清唇形同步** - LatentSync 1.6 驱动,512×512 高分辨率 Latent Diffusion 模型。
|
||||
- 🎙️ **多模态配音** - 支持 **EdgeTTS** (微软超自然语音, 10 语言) 和 **CosyVoice 3.0** (3秒极速声音克隆, 9语言+18方言, 语速可调)。上传参考音频自动 Whisper 转写 + 智能截取。配音前置工作流:先生成配音 → 选素材 → 生成视频。
|
||||
- 🎬 **高清唇形同步** - 混合方案:短视频(本仓库当前 `.env` 阈值 100s,可配)用 LatentSync 1.6(高质量 Latent Diffusion),长视频用 MuseTalk 1.5(实时级单步推理),自动路由 + 回退。前端可选模型:默认模型(阈值自动路由)/ 快速模型(速度优先)/ 高级模型(质量优先)。
|
||||
- 🎙️ **多模态配音** - 支持 **EdgeTTS** (微软超自然语音, 10 语言) 和 **CosyVoice 3.0** (3秒极速声音克隆, 9语言+18方言, 语速/语气可调)。上传参考音频自动 Whisper 转写 + 智能截取。配音前置工作流:先生成配音 → 选素材 → 生成视频。
|
||||
- 📝 **智能字幕** - 集成 faster-whisper + Remotion,自动生成逐字高亮 (卡拉OK效果) 字幕。
|
||||
- 🎨 **样式预设** - 标题/字幕样式选择 + 预览 + 字号调节,支持自定义字体库。
|
||||
- 🏷️ **标题显示模式** - 片头标题支持 `短暂显示` / `常驻显示`,默认短暂显示(4秒),用户偏好自动持久化。
|
||||
- 🖼️ **作品预览一致性** - 标题/字幕预览与 Remotion 成片统一响应式缩放和自动换行,窄屏画布也稳定显示。
|
||||
- 🎞️ **多素材多机位** - 支持多选素材 + 时间轴编辑器 (wavesurfer.js 波形可视化),拖拽分割线调整时长、拖拽排序切换机位、按 `source_start/source_end` 截取片段。
|
||||
- 📐 **画面比例控制** - 时间轴一键切换 `9:16 / 16:9` 输出比例,生成链路全程按目标比例处理。
|
||||
- 💾 **用户偏好持久化** - 首页状态统一恢复/保存,刷新后延续上次配置。历史文案手动保存与加载。
|
||||
- 🎵 **背景音乐** - 试听 + 音量控制 + 混音,保持配音音量稳定。
|
||||
- 🤖 **AI 辅助创作** - 内置 GLM-4.7-Flash,支持 B站/抖音链接文案提取、AI 洗稿、标题/标签自动生成、9 语言翻译。
|
||||
- 🎨 **样式预设** - 12 种标题 + 8 种字幕样式预设,支持预览 + 字号调节 + 自定义字体库。CSS 原生描边渲染,清晰无重影。
|
||||
- 🏷️ **标题显示模式** - 片头标题支持 `短暂显示` / `常驻显示`,默认短暂显示(4秒),用户偏好自动持久化。
|
||||
- 📌 **片头副标题** - 可选副标题显示在主标题下方,独立样式配置,AI 可同时生成,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 / 相对路径作品预览,确保可直接播放。
|
||||
- 📸 **发布结果可视化** - 抖音/微信视频号发布成功后返回截图,发布页结果卡片可直接查看。
|
||||
- 🛡️ **发布防误操作** - 发布进行中自动提示“请勿刷新或关闭网页”,并拦截刷新/关页二次确认。
|
||||
- 🖥️ **发布管理预览** - 支持签名 URL / 相对路径作品预览,确保可直接播放。
|
||||
- 📸 **发布结果可视化** - 抖音/微信视频号/小红书发布成功后返回截图,发布页结果卡片可直接查看。
|
||||
- 🧹 **发布后工作区清理引导** - 全平台发布成功后弹出不可误关清理弹窗(失败可重试,达到阈值可暂不清理),仅清输入内容并保留用户偏好。
|
||||
- ⬇️ **一键下载直达** - 首页与发布成功弹窗下载统一走同源 `attachment` 接口,不再新开标签页播放视频。
|
||||
- 🛡️ **发布防误操作** - 发布进行中自动提示“请勿刷新或关闭网页”,并拦截刷新/关页二次确认。
|
||||
- 💳 **付费会员** - 支付宝电脑网站支付自动开通会员,到期自动停用并引导续费,管理员手动激活并存。
|
||||
- 🔐 **认证与隔离** - 基于 Supabase 的用户隔离,支持手机号注册/登录、密码管理。
|
||||
- 🛡️ **服务守护** - 内置 Watchdog 看门狗机制,自动监控并重启僵死服务,确保 7x24h 稳定运行。
|
||||
- 🚀 **性能优化** - 视频预压缩、模型常驻服务(近实时加载)、双 GPU 流水线并发。
|
||||
- 🚀 **性能优化** - 编码流水线从 5-6 次有损编码精简至 3 次(prepare_segment → 模型输出 → Remotion)、compose 流复制免重编码、同分辨率跳过 scale、FFmpeg 超时保护、全局视频生成并发限制 (Semaphore(2))、Remotion 4 并发渲染、MuseTalk rawvideo 管道直编码(消除中间有损文件)、模型常驻服务、双 GPU 流水线并发、Redis 任务 TTL 自动清理、workflow 阻塞调用线程池化。
|
||||
|
||||
---
|
||||
|
||||
@@ -44,9 +49,9 @@
|
||||
| 领域 | 核心技术 | 说明 |
|
||||
|------|----------|------|
|
||||
| **前端** | Next.js 16 | TypeScript, TailwindCSS, SWR, wavesurfer.js |
|
||||
| **后端** | FastAPI | Python 3.10, AsyncIO, PM2 |
|
||||
| **后端** | FastAPI | Python 3.12, AsyncIO, PM2 |
|
||||
| **数据库** | Supabase | PostgreSQL, Storage (本地/S3), Auth |
|
||||
| **唇形同步** | LatentSync 1.6 | PyTorch 2.5, Diffusers, DeepCache |
|
||||
| **唇形同步** | LatentSync 1.6 + MuseTalk 1.5 | 混合路由:短视频 Diffusion 高质量,长视频单步实时推理 |
|
||||
| **声音克隆** | CosyVoice 3.0 | 0.5B 参数量,9 语言 + 18 方言 |
|
||||
| **自动化** | Playwright | 社交媒体无头浏览器自动化 |
|
||||
| **部署** | Docker & PM2 | 混合部署架构 |
|
||||
@@ -58,15 +63,20 @@
|
||||
我们提供了详尽的开发与部署文档:
|
||||
|
||||
### 部署运维
|
||||
- **[部署手册 (DEPLOY_MANUAL.md)](Docs/DEPLOY_MANUAL.md)** - 👈 **部署请看这里**!包含完整的环境搭建步骤。
|
||||
- [参考音频服务部署 (COSYVOICE3_DEPLOY.md)](Docs/COSYVOICE3_DEPLOY.md) - 声音克隆模型部署指南。
|
||||
- [LatentSync 部署指南](models/LatentSync/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 与认证系统配置。
|
||||
- [支付宝部署指南 (ALIPAY_DEPLOY.md)](Docs/ALIPAY_DEPLOY.md) - 支付宝付费开通会员配置。
|
||||
|
||||
### 开发文档
|
||||
- [后端开发指南](Docs/BACKEND_README.md) - 接口规范与开发流程。
|
||||
- [后端开发规范](Docs/BACKEND_DEV.md) - 分层约定与开发习惯。
|
||||
- [前端开发指南](Docs/FRONTEND_DEV.md) - UI 组件与页面规范。
|
||||
- [后端开发指南 (BACKEND_README.md)](Docs/BACKEND_README.md) - 接口规范与开发流程。
|
||||
- [后端开发规范 (BACKEND_DEV.md)](Docs/BACKEND_DEV.md) - 分层约定与开发习惯。
|
||||
- [前端开发指南 (FRONTEND_DEV.md)](Docs/FRONTEND_DEV.md) - UI 组件与页面规范。
|
||||
- [前端组件文档 (FRONTEND_README.md)](Docs/FRONTEND_README.md) - 组件结构与板块说明。
|
||||
- [Remotion 字幕部署 (SUBTITLE_DEPLOY.md)](Docs/SUBTITLE_DEPLOY.md) - 字幕渲染服务部署。
|
||||
- [开发日志 (DevLogs)](Docs/DevLogs/) - 每日开发进度与技术决策记录。
|
||||
|
||||
---
|
||||
@@ -83,7 +93,8 @@ ViGent2/
|
||||
├── frontend/ # Next.js 前端应用
|
||||
├── remotion/ # Remotion 视频渲染 (标题/字幕合成)
|
||||
├── models/ # AI 模型仓库
|
||||
│ ├── LatentSync/ # 唇形同步服务
|
||||
│ ├── LatentSync/ # 唇形同步服务 (GPU1, 短视频)
|
||||
│ ├── MuseTalk/ # 唇形同步服务 (GPU0, 长视频)
|
||||
│ └── CosyVoice/ # 声音克隆服务
|
||||
└── Docs/ # 项目文档
|
||||
```
|
||||
@@ -98,7 +109,8 @@ ViGent2/
|
||||
|----------|------|------|
|
||||
| **Web UI** | 3002 | 用户访问入口 (Next.js) |
|
||||
| **Backend API** | 8006 | 核心业务接口 (FastAPI) |
|
||||
| **LatentSync** | 8007 | 唇形同步推理服务 |
|
||||
| **LatentSync** | 8007 | 唇形同步推理服务 (GPU1, 短视频) |
|
||||
| **MuseTalk** | 8011 | 唇形同步推理服务 (GPU0, 长视频) |
|
||||
| **CosyVoice 3.0** | 8010 | 声音克隆推理服务 |
|
||||
| **Supabase** | 8008 | 数据库与认证网关 |
|
||||
|
||||
|
||||
@@ -25,10 +25,10 @@ LATENTSYNC_USE_SERVER=true
|
||||
# LATENTSYNC_API_URL=http://localhost:8007
|
||||
|
||||
# 推理步数 (20-50, 越高质量越好,速度越慢)
|
||||
LATENTSYNC_INFERENCE_STEPS=40
|
||||
LATENTSYNC_INFERENCE_STEPS=30
|
||||
|
||||
# 引导系数 (1.0-3.0, 越高唇同步越准,但可能抖动)
|
||||
LATENTSYNC_GUIDANCE_SCALE=2.0
|
||||
LATENTSYNC_GUIDANCE_SCALE=1.9
|
||||
|
||||
# 启用 DeepCache 加速 (推荐开启)
|
||||
LATENTSYNC_ENABLE_DEEPCACHE=true
|
||||
@@ -36,6 +36,53 @@ LATENTSYNC_ENABLE_DEEPCACHE=true
|
||||
# 随机种子 (设为 -1 则随机)
|
||||
LATENTSYNC_SEED=1247
|
||||
|
||||
# =============== MuseTalk 配置 ===============
|
||||
# GPU 选择 (默认 GPU0,与 CosyVoice 共存)
|
||||
MUSETALK_GPU_ID=0
|
||||
|
||||
# 常驻服务地址 (端口 8011)
|
||||
MUSETALK_API_URL=http://localhost:8011
|
||||
|
||||
# 推理批大小
|
||||
MUSETALK_BATCH_SIZE=32
|
||||
|
||||
# 模型版本
|
||||
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=100
|
||||
|
||||
# =============== 上传配置 ===============
|
||||
# 最大上传文件大小 (MB)
|
||||
MAX_UPLOAD_SIZE_MB=500
|
||||
@@ -70,6 +117,9 @@ GLM_MODEL=glm-4.7-flash
|
||||
# 确保存储卷映射正确,避免硬编码路径
|
||||
SUPABASE_STORAGE_LOCAL_PATH=/home/rongye/ProgramFiles/Supabase/volumes/storage/stub/stub
|
||||
|
||||
# =============== 抖音视频下载 Cookie ===============
|
||||
# 用于从抖音 URL 提取视频文案功能,会过期需要定期更新
|
||||
DOUYIN_COOKIE=douyin.com; device_web_cpu_core=10; device_web_memory_size=8; __ac_nonce=06760391f00b9b51264ae; __ac_signature=_02B4Z6wo00f019a5ceAAAIDAhEZR-X3jjWfWmXVAAJLXd4; ttwid=1%7C7MTKBSMsP4eOv9h5NAh8p0E-NYIud09ftNmB0mjLpWc%7C1734359327%7C8794abeabbd47447e1f56e5abc726be089f2a0344d6343b5f75f23e7b0f0028f; UIFID_TEMP=0de8750d2b188f4235dbfd208e44abbb976428f0720eb983255afefa45d39c0c6532e1d4768dd8587bf919f866ff1396912bcb2af71efee56a14a2a9f37b74010d0a0413795262f6d4afe02a032ac7ab; s_v_web_id=verify_m4r4ribr_c7krmY1z_WoeI_43po_ATpO_I4o8U1bex2D7; hevc_supported=true; home_can_add_dy_2_desktop=%220%22; dy_swidth=2560; dy_sheight=1440; stream_recommend_feed_params=%22%7B%5C%22cookie_enabled%5C%22%3Atrue%2C%5C%22screen_width%5C%22%3A2560%2C%5C%22screen_height%5C%22%3A1440%2C%5C%22browser_online%5C%22%3Atrue%2C%5C%22cpu_core_num%5C%22%3A10%2C%5C%22device_memory%5C%22%3A8%2C%5C%22downlink%5C%22%3A10%2C%5C%22effective_type%5C%22%3A%5C%224g%5C%22%2C%5C%22round_trip_time%5C%22%3A50%7D%22; strategyABtestKey=%221734359328.577%22; csrf_session_id=2f53aed9aa6974e83aa9a1014180c3a4; fpk1=U2FsdGVkX1/IpBh0qdmlKAVhGyYHgur4/VtL9AReZoeSxadXn4juKvsakahRGqjxOPytHWspYoBogyhS/V6QSw==; fpk2=0845b309c7b9b957afd9ecf775a4c21f; passport_csrf_token=d80e0c5b2fa2328219856be5ba7e671e; passport_csrf_token_default=d80e0c5b2fa2328219856be5ba7e671e; odin_tt=3c891091d2eb0f4718c1d5645bc4a0017032d4d5aa989decb729e9da2ad570918cbe5e9133dc6b145fa8c758de98efe32ff1f81aa0d611e838cc73ab08ef7d3f6adf66ab4d10e8372ddd628f94f16b8e; volume_info=%7B%22isUserMute%22%3Afalse%2C%22isMute%22%3Afalse%2C%22volume%22%3A0.5%7D; bd_ticket_guard_client_web_domain=2; FORCE_LOGIN=%7B%22videoConsumedRemainSeconds%22%3A180%7D; UIFID=0de8750d2b188f4235dbfd208e44abbb976428f0720eb983255afefa45d39c0c6532e1d4768dd8587bf919f866ff139655a3c2b735923234f371c699560c657923fd3d6c5b63ab7bb9b83423b6cb4787e2ce66a7fbc4ecb24c8570f520fe6de068bbb95115023c0c6c1b6ee31b49fb7e3996fb8349f43a3fd8b7a61cd9e18e8fe65eb6a7c13de4c0960d84e344b644725db3eb2fa6b7caf821de1b50527979f2; is_dash_user=1; biz_trace_id=b57a241f; bd_ticket_guard_client_data=eyJiZC10aWNrZXQtZ3VhcmQtdmVyc2lvbiI6MiwiYmQtdGlja2V0LWd1YXJkLWl0ZXJhdGlvbi12ZXJzaW9uIjoxLCJiZC10aWNrZXQtZ3VhcmQtcmVlLXB1YmxpYy1rZXkiOiJCTEo2R0lDalVoWW1XcHpGOFdrN0Vrc0dXcCtaUzNKY1g4NGNGY2k0TTl1TEowNjdUb21mbFU5aDdvWVBGamhNRWNRQWtKdnN1MnM3RmpTWnlJQXpHMjA9IiwiYmQtdGlja2V0LWd1YXJkLXdlYi12ZXJzaW9uIjoyfQ%3D%3D; download_guide=%221%2F20241216%2F0%22; sdk_source_info=7e276470716a68645a606960273f276364697660272927676c715a6d6069756077273f276364697660272927666d776a68605a607d71606b766c6a6b5a7666776c7571273f275e58272927666a6b766a69605a696c6061273f27636469766027292762696a6764695a7364776c6467696076273f275e5827292771273f273d33323131333c3036313632342778; bit_env=RiOY4jzzpxZoVCl6zdVSVhVRjdwHRTxqcqWdqMBZLPGjMdB4Tax1kAELHNTVAAh72KuhumewE4Lq6f0-VJ2UpJrkrhSxoPw9LUb3zQrq1OSwbeSPHkRlRgRQvO89sItdGUyq1oFr0XyRCnMYG87KSeWyc4x0czGR0o50hTDoDLG5rJVoRcdQOLvjiAegsqyytKF59sPX_QM9qffK2SqYsg0hCggURc_AI6kguDDE5DvG0bnyz1utw4z1eEnIoLrkGDqzqBZj4dOAr0BVU6ofbsS-pOQ2u2PM1dLP9FlBVBlVaqYVgHJeSLsR5k76BRTddUjTb4zEilVIEwAMJWGN4I1BxVt6fC9B5tBQpuT0lj3n3eKXCKXZsd8FrEs5_pbfDsxV-e_WMiXI2ff4qxiTC0U73sfo9OpicKICtZjdq8qsHxJuu6wVR36zvXeL2Wch5C6MzprNvkivv0l8nbh2mSgy1nabZr3dmU6NcR-Bg3Q3xTWUlR9aAUmpopC-cNuXjgLpT-Lw1AYGilSUnCvosth1Gfypq-b0MpgmdSDgTrQ%3D; gulu_source_res=eyJwX2luIjoiMDhjOGQ3ZTJiODQyNjZkZWI5Y2VkMGJiODNlNmY1ZWY0ZjMyNTE2ZmYyZjAzNDMzZjI0OWU1Y2Q1NTczNTk5NyJ9; passport_auth_mix_state=hp9bc3dgb1tm5wd8p82zawus27g0e3ue; IsDouyinActive=false
|
||||
# =============== 支付宝配置 ===============
|
||||
ALIPAY_APP_ID=2021006132600283
|
||||
ALIPAY_PRIVATE_KEY_PATH=/home/rongye/ProgramFiles/ViGent2/backend/keys/app_private_key.pem
|
||||
ALIPAY_PUBLIC_KEY_PATH=/home/rongye/ProgramFiles/ViGent2/backend/keys/alipay_public_key.pem
|
||||
ALIPAY_NOTIFY_URL=https://ipagent.ai-labz.cn/api/payment/notify
|
||||
ALIPAY_RETURN_URL=https://ipagent.ai-labz.cn/pay
|
||||
|
||||
@@ -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"
|
||||
@@ -57,7 +67,17 @@ class Settings(BaseSettings):
|
||||
LATENTSYNC_ENABLE_DEEPCACHE: bool = True # 启用 DeepCache 加速
|
||||
LATENTSYNC_SEED: int = 1247 # 随机种子 (-1 则随机)
|
||||
LATENTSYNC_USE_SERVER: bool = True # 使用常驻服务 (Persistent Server) 加速
|
||||
|
||||
|
||||
# MuseTalk 配置
|
||||
MUSETALK_GPU_ID: int = 0 # GPU ID (默认使用 GPU0)
|
||||
MUSETALK_API_URL: str = "http://localhost:8011" # 常驻服务地址
|
||||
MUSETALK_BATCH_SIZE: int = 8 # 推理批大小
|
||||
MUSETALK_VERSION: str = "v15" # 模型版本
|
||||
MUSETALK_USE_FLOAT16: bool = True # 半精度加速
|
||||
|
||||
# 混合唇形同步路由
|
||||
LIPSYNC_DURATION_THRESHOLD: float = 120.0 # 秒,>=此值用 MuseTalk
|
||||
|
||||
# Supabase 配置
|
||||
SUPABASE_URL: str = ""
|
||||
SUPABASE_PUBLIC_URL: str = "" # 公网访问地址,用于生成前端可访问的 URL
|
||||
@@ -76,17 +96,28 @@ class Settings(BaseSettings):
|
||||
GLM_API_KEY: str = ""
|
||||
GLM_MODEL: str = "glm-4.7-flash"
|
||||
|
||||
# 支付宝配置
|
||||
ALIPAY_APP_ID: str = ""
|
||||
ALIPAY_PRIVATE_KEY_PATH: str = "" # 应用私钥 PEM 文件路径
|
||||
ALIPAY_PUBLIC_KEY_PATH: str = "" # 支付宝公钥 PEM 文件路径
|
||||
ALIPAY_NOTIFY_URL: str = "" # 异步通知回调地址(公网可达)
|
||||
ALIPAY_RETURN_URL: str = "" # 支付成功后同步跳转地址
|
||||
ALIPAY_SANDBOX: bool = False # 是否使用沙箱环境
|
||||
PAYMENT_AMOUNT: float = 999.00 # 会员价格(元)
|
||||
PAYMENT_EXPIRE_DAYS: int = 365 # 会员有效天数
|
||||
|
||||
# CORS 配置 (逗号分隔的域名列表,* 表示允许所有)
|
||||
CORS_ORIGINS: str = "*"
|
||||
|
||||
# 抖音 Cookie (用于视频下载功能,会过期需要定期更新)
|
||||
DOUYIN_COOKIE: str = ""
|
||||
|
||||
@property
|
||||
def LATENTSYNC_DIR(self) -> Path:
|
||||
"""LatentSync 目录路径 (动态计算)"""
|
||||
return self.BASE_DIR.parent.parent / "models" / "LatentSync"
|
||||
|
||||
@property
|
||||
def MUSETALK_DIR(self) -> Path:
|
||||
"""MuseTalk 目录路径 (动态计算)"""
|
||||
return self.BASE_DIR.parent.parent / "models" / "MuseTalk"
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
extra = "ignore" # 忽略未知的环境变量
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
"""
|
||||
依赖注入模块:认证和用户获取
|
||||
"""
|
||||
from typing import Optional, Any, Dict, cast
|
||||
from fastapi import Request, HTTPException, Depends, status
|
||||
from app.core.security import decode_access_token
|
||||
from app.repositories.sessions import get_session, delete_sessions
|
||||
from app.repositories.users import get_user_by_id, deactivate_user_if_expired
|
||||
from loguru import logger
|
||||
from typing import Optional, Any, Dict, cast
|
||||
from fastapi import Request, HTTPException, Depends, status
|
||||
from app.core.security import decode_access_token
|
||||
from app.repositories.sessions import get_session, delete_sessions
|
||||
from app.repositories.users import get_user_by_id, deactivate_user_if_expired
|
||||
from loguru import logger
|
||||
|
||||
|
||||
async def get_token_from_cookie(request: Request) -> Optional[str]:
|
||||
@@ -14,9 +14,9 @@ async def get_token_from_cookie(request: Request) -> Optional[str]:
|
||||
return request.cookies.get("access_token")
|
||||
|
||||
|
||||
async def get_current_user_optional(
|
||||
request: Request
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
async def get_current_user_optional(
|
||||
request: Request
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
获取当前用户 (可选,未登录返回 None)
|
||||
"""
|
||||
@@ -29,26 +29,30 @@ async def get_current_user_optional(
|
||||
return None
|
||||
|
||||
# 验证 session_token 是否有效 (单设备登录检查)
|
||||
try:
|
||||
session = get_session(token_data.user_id, token_data.session_token)
|
||||
if not session:
|
||||
logger.warning(f"Session token 无效: user_id={token_data.user_id}")
|
||||
return None
|
||||
|
||||
user = cast(Optional[Dict[str, Any]], get_user_by_id(token_data.user_id))
|
||||
if user and deactivate_user_if_expired(user):
|
||||
delete_sessions(token_data.user_id)
|
||||
return None
|
||||
|
||||
return user
|
||||
except Exception as e:
|
||||
logger.error(f"获取用户信息失败: {e}")
|
||||
return None
|
||||
try:
|
||||
session = get_session(token_data.user_id, token_data.session_token)
|
||||
if not session:
|
||||
logger.warning(f"Session token 无效: user_id={token_data.user_id}")
|
||||
return None
|
||||
|
||||
user = cast(Optional[Dict[str, Any]], get_user_by_id(token_data.user_id))
|
||||
if user and deactivate_user_if_expired(user):
|
||||
delete_sessions(token_data.user_id)
|
||||
return None
|
||||
|
||||
if user and not user.get("is_active"):
|
||||
delete_sessions(token_data.user_id)
|
||||
return None
|
||||
|
||||
return user
|
||||
except Exception as e:
|
||||
logger.error(f"获取用户信息失败: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
request: Request
|
||||
) -> Dict[str, Any]:
|
||||
async def get_current_user(
|
||||
request: Request
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
获取当前用户 (必须登录)
|
||||
|
||||
@@ -70,38 +74,45 @@ async def get_current_user(
|
||||
detail="Token 无效或已过期"
|
||||
)
|
||||
|
||||
try:
|
||||
session = get_session(token_data.user_id, token_data.session_token)
|
||||
if not session:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="会话已失效,请重新登录(可能已在其他设备登录)"
|
||||
)
|
||||
|
||||
user = get_user_by_id(token_data.user_id)
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="用户不存在"
|
||||
)
|
||||
user = cast(Dict[str, Any], user)
|
||||
|
||||
if deactivate_user_if_expired(user):
|
||||
delete_sessions(token_data.user_id)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="会员已到期,请续费"
|
||||
)
|
||||
|
||||
return user
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"获取用户信息失败: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="服务器错误"
|
||||
)
|
||||
try:
|
||||
session = get_session(token_data.user_id, token_data.session_token)
|
||||
if not session:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="会话已失效,请重新登录(可能已在其他设备登录)"
|
||||
)
|
||||
|
||||
user = get_user_by_id(token_data.user_id)
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="用户不存在"
|
||||
)
|
||||
user = cast(Dict[str, Any], user)
|
||||
|
||||
if deactivate_user_if_expired(user):
|
||||
delete_sessions(token_data.user_id)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="会员已到期,请续费"
|
||||
)
|
||||
|
||||
if not user.get("is_active"):
|
||||
delete_sessions(token_data.user_id)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="账号已停用"
|
||||
)
|
||||
|
||||
return user
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"获取用户信息失败: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="服务器错误"
|
||||
)
|
||||
|
||||
|
||||
async def get_current_admin(
|
||||
|
||||
@@ -110,3 +110,28 @@ def set_auth_cookie(response: Response, token: str) -> None:
|
||||
def clear_auth_cookie(response: Response) -> None:
|
||||
"""清除认证 Cookie"""
|
||||
response.delete_cookie(key="access_token")
|
||||
|
||||
|
||||
def create_payment_token(user_id: str) -> str:
|
||||
"""生成付费专用短期 JWT token(30 分钟有效)"""
|
||||
payload = {
|
||||
"sub": user_id,
|
||||
"purpose": "payment",
|
||||
"exp": datetime.now(timezone.utc) + timedelta(minutes=30),
|
||||
}
|
||||
return jwt.encode(payload, settings.JWT_SECRET_KEY, algorithm=settings.JWT_ALGORITHM)
|
||||
|
||||
|
||||
def decode_payment_token(token: str) -> str | None:
|
||||
"""解析 payment_token,返回 user_id(仅 purpose=payment 有效)"""
|
||||
try:
|
||||
data = jwt.decode(
|
||||
token,
|
||||
settings.JWT_SECRET_KEY,
|
||||
algorithms=[settings.JWT_ALGORITHM],
|
||||
)
|
||||
if data.get("purpose") != "payment":
|
||||
return None
|
||||
return data.get("sub")
|
||||
except JWTError:
|
||||
return None
|
||||
|
||||
@@ -16,6 +16,7 @@ from app.modules.ai.router import router as ai_router
|
||||
from app.modules.tools.router import router as tools_router
|
||||
from app.modules.assets.router import router as assets_router
|
||||
from app.modules.generated_audios.router import router as generated_audios_router
|
||||
from app.modules.payment.router import router as payment_router
|
||||
from loguru import logger
|
||||
import os
|
||||
|
||||
@@ -126,6 +127,7 @@ app.include_router(ai_router) # /api/ai
|
||||
app.include_router(tools_router, prefix="/api/tools", tags=["Tools"])
|
||||
app.include_router(assets_router, prefix="/api/assets", tags=["Assets"])
|
||||
app.include_router(generated_audios_router, prefix="/api/generated-audios", tags=["GeneratedAudios"])
|
||||
app.include_router(payment_router) # /api/payment
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
AI 相关 API 路由
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from loguru import logger
|
||||
@@ -21,9 +23,16 @@ class GenerateMetaRequest(BaseModel):
|
||||
class GenerateMetaResponse(BaseModel):
|
||||
"""生成标题标签响应"""
|
||||
title: str
|
||||
secondary_title: str = ""
|
||||
tags: list[str]
|
||||
|
||||
|
||||
class RewriteRequest(BaseModel):
|
||||
"""改写请求"""
|
||||
text: str
|
||||
custom_prompt: Optional[str] = None
|
||||
|
||||
|
||||
class TranslateRequest(BaseModel):
|
||||
"""翻译请求"""
|
||||
text: str
|
||||
@@ -66,8 +75,24 @@ async def generate_meta(req: GenerateMetaRequest):
|
||||
result = await glm_service.generate_title_tags(req.text)
|
||||
return success_response(GenerateMetaResponse(
|
||||
title=result.get("title", ""),
|
||||
secondary_title=result.get("secondary_title", ""),
|
||||
tags=result.get("tags", [])
|
||||
).model_dump())
|
||||
except Exception as e:
|
||||
logger.error(f"Generate meta failed: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/rewrite")
|
||||
async def rewrite_script(req: RewriteRequest):
|
||||
"""AI 改写文案"""
|
||||
if not req.text or not req.text.strip():
|
||||
raise HTTPException(status_code=400, detail="文案不能为空")
|
||||
|
||||
try:
|
||||
logger.info(f"Rewriting text: {req.text[:50]}...")
|
||||
rewritten = await glm_service.rewrite_script(req.text.strip(), req.custom_prompt)
|
||||
return success_response({"rewritten_text": rewritten})
|
||||
except Exception as e:
|
||||
logger.error(f"Rewrite failed: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@@ -1,30 +1,32 @@
|
||||
"""
|
||||
认证 API:注册、登录、登出、修改密码
|
||||
"""
|
||||
from fastapi import APIRouter, HTTPException, Response, status, Request, Depends
|
||||
from fastapi import APIRouter, HTTPException, Response, status, Request, Depends
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel, field_validator
|
||||
from app.core.security import (
|
||||
get_password_hash,
|
||||
verify_password,
|
||||
create_access_token,
|
||||
generate_session_token,
|
||||
set_auth_cookie,
|
||||
clear_auth_cookie,
|
||||
decode_access_token
|
||||
)
|
||||
from app.repositories.sessions import create_session, delete_sessions
|
||||
from app.repositories.users import (
|
||||
create_user,
|
||||
get_user_by_id,
|
||||
get_user_by_phone,
|
||||
user_exists_by_phone,
|
||||
update_user,
|
||||
deactivate_user_if_expired,
|
||||
)
|
||||
from app.core.deps import get_current_user
|
||||
from app.core.response import success_response
|
||||
from app.core.security import (
|
||||
get_password_hash,
|
||||
verify_password,
|
||||
create_access_token,
|
||||
generate_session_token,
|
||||
set_auth_cookie,
|
||||
clear_auth_cookie,
|
||||
decode_access_token,
|
||||
create_payment_token,
|
||||
)
|
||||
from app.repositories.sessions import create_session, delete_sessions
|
||||
from app.repositories.users import (
|
||||
create_user,
|
||||
get_user_by_id,
|
||||
get_user_by_phone,
|
||||
user_exists_by_phone,
|
||||
update_user,
|
||||
deactivate_user_if_expired,
|
||||
)
|
||||
from app.core.deps import get_current_user
|
||||
from app.core.response import success_response
|
||||
from loguru import logger
|
||||
from typing import Optional, Any, cast
|
||||
from typing import Optional, Any, cast
|
||||
import re
|
||||
|
||||
router = APIRouter(prefix="/api/auth", tags=["认证"])
|
||||
@@ -84,26 +86,26 @@ async def register(request: RegisterRequest):
|
||||
注册后状态为 pending,需要管理员激活
|
||||
"""
|
||||
try:
|
||||
if user_exists_by_phone(request.phone):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="该手机号已注册"
|
||||
)
|
||||
if user_exists_by_phone(request.phone):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="该手机号已注册"
|
||||
)
|
||||
|
||||
# 创建用户
|
||||
password_hash = get_password_hash(request.password)
|
||||
|
||||
create_user({
|
||||
"phone": request.phone,
|
||||
"password_hash": password_hash,
|
||||
"username": request.username or f"用户{request.phone[-4:]}",
|
||||
"role": "pending",
|
||||
"is_active": False
|
||||
})
|
||||
create_user({
|
||||
"phone": request.phone,
|
||||
"password_hash": password_hash,
|
||||
"username": request.username or f"用户{request.phone[-4:]}",
|
||||
"role": "pending",
|
||||
"is_active": False
|
||||
})
|
||||
|
||||
logger.info(f"新用户注册: {request.phone}")
|
||||
|
||||
return success_response(message="注册成功,请等待管理员审核激活")
|
||||
return success_response(message="注册成功,请等待管理员审核激活")
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
@@ -124,12 +126,12 @@ async def login(request: LoginRequest, response: Response):
|
||||
- 实现"后踢前"单设备登录
|
||||
"""
|
||||
try:
|
||||
user = cast(dict[str, Any], get_user_by_phone(request.phone) or {})
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="手机号或密码错误"
|
||||
)
|
||||
user = cast(dict[str, Any], get_user_by_phone(request.phone) or {})
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="手机号或密码错误"
|
||||
)
|
||||
|
||||
# 验证密码
|
||||
if not verify_password(request.password, user["password_hash"]):
|
||||
@@ -138,27 +140,33 @@ async def login(request: LoginRequest, response: Response):
|
||||
detail="手机号或密码错误"
|
||||
)
|
||||
|
||||
# 授权过期时自动停用账号
|
||||
if deactivate_user_if_expired(user):
|
||||
delete_sessions(user["id"])
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="会员已到期,请续费"
|
||||
)
|
||||
|
||||
# 检查是否激活
|
||||
if not user["is_active"]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="账号未激活,请等待管理员审核"
|
||||
)
|
||||
# 过期自动停用(注意:只更新 DB,不修改内存中的 user 字典)
|
||||
expired = deactivate_user_if_expired(user)
|
||||
if expired:
|
||||
delete_sessions(user["id"])
|
||||
|
||||
# 过期 或 未激活(新注册)→ 返回付费指引
|
||||
if expired or not user["is_active"]:
|
||||
payment_token = create_payment_token(user["id"])
|
||||
return JSONResponse(
|
||||
status_code=403,
|
||||
content={
|
||||
"success": False,
|
||||
"message": "请付费开通会员",
|
||||
"code": 403,
|
||||
"data": {
|
||||
"reason": "PAYMENT_REQUIRED",
|
||||
"payment_token": payment_token,
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
# 生成新的 session_token (后踢前)
|
||||
session_token = generate_session_token()
|
||||
|
||||
# 删除旧 session,插入新 session
|
||||
delete_sessions(user["id"])
|
||||
create_session(user["id"], session_token, None)
|
||||
delete_sessions(user["id"])
|
||||
create_session(user["id"], session_token, None)
|
||||
|
||||
# 生成 JWT Token
|
||||
token = create_access_token(user["id"], session_token)
|
||||
@@ -168,19 +176,19 @@ async def login(request: LoginRequest, response: Response):
|
||||
|
||||
logger.info(f"用户登录: {request.phone}")
|
||||
|
||||
return success_response(
|
||||
data={
|
||||
"user": UserResponse(
|
||||
id=user["id"],
|
||||
phone=user["phone"],
|
||||
username=user.get("username"),
|
||||
role=user["role"],
|
||||
is_active=user["is_active"],
|
||||
expires_at=user.get("expires_at")
|
||||
).model_dump()
|
||||
},
|
||||
message="登录成功",
|
||||
)
|
||||
return success_response(
|
||||
data={
|
||||
"user": UserResponse(
|
||||
id=user["id"],
|
||||
phone=user["phone"],
|
||||
username=user.get("username"),
|
||||
role=user["role"],
|
||||
is_active=user["is_active"],
|
||||
expires_at=user.get("expires_at")
|
||||
).model_dump()
|
||||
},
|
||||
message="登录成功",
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
@@ -192,10 +200,10 @@ async def login(request: LoginRequest, response: Response):
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
async def logout(response: Response):
|
||||
"""用户登出"""
|
||||
clear_auth_cookie(response)
|
||||
return success_response(message="已登出")
|
||||
async def logout(response: Response):
|
||||
"""用户登出"""
|
||||
clear_auth_cookie(response)
|
||||
return success_response(message="已登出")
|
||||
|
||||
|
||||
@router.post("/change-password")
|
||||
@@ -223,12 +231,12 @@ async def change_password(request: ChangePasswordRequest, req: Request, response
|
||||
)
|
||||
|
||||
try:
|
||||
user = cast(dict[str, Any], get_user_by_id(token_data.user_id) or {})
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="用户不存在"
|
||||
)
|
||||
user = cast(dict[str, Any], get_user_by_id(token_data.user_id) or {})
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="用户不存在"
|
||||
)
|
||||
|
||||
# 验证当前密码
|
||||
if not verify_password(request.old_password, user["password_hash"]):
|
||||
@@ -239,13 +247,13 @@ async def change_password(request: ChangePasswordRequest, req: Request, response
|
||||
|
||||
# 更新密码
|
||||
new_password_hash = get_password_hash(request.new_password)
|
||||
update_user(user["id"], {"password_hash": new_password_hash})
|
||||
update_user(user["id"], {"password_hash": new_password_hash})
|
||||
|
||||
# 生成新的 session token,使旧 token 失效
|
||||
new_session_token = generate_session_token()
|
||||
|
||||
delete_sessions(user["id"])
|
||||
create_session(user["id"], new_session_token, None)
|
||||
delete_sessions(user["id"])
|
||||
create_session(user["id"], new_session_token, None)
|
||||
|
||||
# 生成新的 JWT Token
|
||||
new_token = create_access_token(user["id"], new_session_token)
|
||||
@@ -253,7 +261,7 @@ async def change_password(request: ChangePasswordRequest, req: Request, response
|
||||
|
||||
logger.info(f"用户修改密码: {user['phone']}")
|
||||
|
||||
return success_response(message="密码修改成功")
|
||||
return success_response(message="密码修改成功")
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
@@ -264,14 +272,14 @@ async def change_password(request: ChangePasswordRequest, req: Request, response
|
||||
)
|
||||
|
||||
|
||||
@router.get("/me")
|
||||
async def get_me(user: dict = Depends(get_current_user)):
|
||||
"""获取当前用户信息"""
|
||||
return success_response(UserResponse(
|
||||
id=user["id"],
|
||||
phone=user["phone"],
|
||||
username=user.get("username"),
|
||||
role=user["role"],
|
||||
is_active=user["is_active"],
|
||||
expires_at=user.get("expires_at")
|
||||
).model_dump())
|
||||
@router.get("/me")
|
||||
async def get_me(user: dict = Depends(get_current_user)):
|
||||
"""获取当前用户信息"""
|
||||
return success_response(UserResponse(
|
||||
id=user["id"],
|
||||
phone=user["phone"],
|
||||
username=user.get("username"),
|
||||
role=user["role"],
|
||||
is_active=user["is_active"],
|
||||
expires_at=user.get("expires_at")
|
||||
).model_dump())
|
||||
|
||||
@@ -10,6 +10,7 @@ class GenerateAudioRequest(BaseModel):
|
||||
ref_text: Optional[str] = None
|
||||
language: str = "zh-CN"
|
||||
speed: float = 1.0
|
||||
instruct_text: Optional[str] = None
|
||||
|
||||
|
||||
class RenameAudioRequest(BaseModel):
|
||||
|
||||
@@ -81,6 +81,7 @@ async def generate_audio_task(task_id: str, req: GenerateAudioRequest, user_id:
|
||||
output_path=audio_path,
|
||||
language=_locale_to_tts_lang(req.language),
|
||||
speed=req.speed,
|
||||
instruct_text=req.instruct_text or "",
|
||||
)
|
||||
finally:
|
||||
if os.path.exists(ref_local):
|
||||
@@ -214,6 +215,30 @@ async def list_generated_audios(user_id: str) -> dict:
|
||||
return GeneratedAudioListResponse(items=items).model_dump()
|
||||
|
||||
|
||||
async def delete_all_generated_audios(user_id: str) -> tuple[int, int]:
|
||||
"""删除用户所有生成的配音(.wav + .json),返回 (删除数量, 失败数量)"""
|
||||
try:
|
||||
files = await storage_service.list_files(BUCKET, user_id, strict=True)
|
||||
deleted_count = 0
|
||||
failed_count = 0
|
||||
for f in files:
|
||||
name = f.get("name", "")
|
||||
if not name or name == ".emptyFolderPlaceholder":
|
||||
continue
|
||||
if name.endswith("_audio.wav") or name.endswith("_audio.json"):
|
||||
full_path = f"{user_id}/{name}"
|
||||
try:
|
||||
await storage_service.delete_file(BUCKET, full_path)
|
||||
deleted_count += 1
|
||||
except Exception as e:
|
||||
failed_count += 1
|
||||
logger.warning(f"Delete audio file failed: {full_path}, {e}")
|
||||
return deleted_count, failed_count
|
||||
except Exception as e:
|
||||
logger.error(f"Delete all generated audios failed: {e}")
|
||||
return 0, 1
|
||||
|
||||
|
||||
async def delete_generated_audio(audio_id: str, user_id: str) -> None:
|
||||
if not audio_id.startswith(f"{user_id}/"):
|
||||
raise PermissionError("无权删除此文件")
|
||||
|
||||
@@ -1,14 +1,28 @@
|
||||
from fastapi import APIRouter, HTTPException, Request, Depends
|
||||
from fastapi.responses import FileResponse
|
||||
from loguru import logger
|
||||
|
||||
from app.core.deps import get_current_user
|
||||
from app.core.response import success_response
|
||||
from app.modules.materials.schemas import RenameMaterialRequest
|
||||
from app.modules.materials import service
|
||||
from app.services.storage import storage_service
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/stream/{material_id:path}")
|
||||
async def stream_material(material_id: str, current_user: dict = Depends(get_current_user)):
|
||||
"""直接流式返回素材文件(同源,避免 CORS canvas taint)"""
|
||||
user_id = current_user["id"]
|
||||
if not material_id.startswith(f"{user_id}/"):
|
||||
raise HTTPException(403, "无权访问此素材")
|
||||
local_path = storage_service.get_local_file_path("materials", material_id)
|
||||
if not local_path:
|
||||
raise HTTPException(404, "素材文件不存在")
|
||||
return FileResponse(local_path, media_type="video/mp4")
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def upload_material(
|
||||
request: Request,
|
||||
|
||||
0
backend/app/modules/payment/__init__.py
Normal file
0
backend/app/modules/payment/__init__.py
Normal file
52
backend/app/modules/payment/router.py
Normal file
52
backend/app/modules/payment/router.py
Normal file
@@ -0,0 +1,52 @@
|
||||
"""
|
||||
支付 API:创建订单、异步通知、状态查询
|
||||
|
||||
遵循 BACKEND_DEV.md 规范:router 只做参数校验、调用 service、返回统一响应
|
||||
"""
|
||||
from fastapi import APIRouter, HTTPException, Request, status
|
||||
from fastapi.responses import PlainTextResponse
|
||||
|
||||
from app.core.response import success_response
|
||||
from .schemas import CreateOrderRequest, CreateOrderResponse, OrderStatusResponse
|
||||
from . import service
|
||||
|
||||
router = APIRouter(prefix="/api/payment", tags=["支付"])
|
||||
|
||||
|
||||
@router.post("/create-order")
|
||||
async def create_payment_order(request: CreateOrderRequest):
|
||||
"""创建支付宝电脑网站支付订单,返回收银台 URL"""
|
||||
try:
|
||||
result = service.create_payment_order(request.payment_token)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=str(e))
|
||||
except RuntimeError as e:
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e))
|
||||
|
||||
return success_response(
|
||||
CreateOrderResponse(**result).model_dump()
|
||||
)
|
||||
|
||||
|
||||
@router.post("/notify")
|
||||
async def payment_notify(request: Request):
|
||||
"""
|
||||
支付宝异步通知回调
|
||||
|
||||
必须返回纯文本 "success"(不是 JSON),否则支付宝会重复推送。
|
||||
"""
|
||||
form_data = await request.form()
|
||||
verified = service.handle_payment_notify(dict(form_data))
|
||||
return PlainTextResponse("success" if verified else "fail")
|
||||
|
||||
|
||||
@router.get("/status/{out_trade_no}")
|
||||
async def check_payment_status(out_trade_no: str):
|
||||
"""查询订单支付状态(前端轮询)"""
|
||||
order_status = service.get_order_status(out_trade_no)
|
||||
if order_status is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="订单不存在")
|
||||
|
||||
return success_response(
|
||||
OrderStatusResponse(status=order_status).model_dump()
|
||||
)
|
||||
15
backend/app/modules/payment/schemas.py
Normal file
15
backend/app/modules/payment/schemas.py
Normal file
@@ -0,0 +1,15 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class CreateOrderRequest(BaseModel):
|
||||
payment_token: str
|
||||
|
||||
|
||||
class CreateOrderResponse(BaseModel):
|
||||
pay_url: str
|
||||
out_trade_no: str
|
||||
amount: float
|
||||
|
||||
|
||||
class OrderStatusResponse(BaseModel):
|
||||
status: str
|
||||
137
backend/app/modules/payment/service.py
Normal file
137
backend/app/modules/payment/service.py
Normal file
@@ -0,0 +1,137 @@
|
||||
"""
|
||||
支付业务服务
|
||||
|
||||
职责:Alipay SDK 封装、创建订单、处理支付通知、查询状态
|
||||
遵循 BACKEND_DEV.md "薄路由 + 厚服务" 原则
|
||||
"""
|
||||
from datetime import datetime, timezone, timedelta
|
||||
import uuid
|
||||
|
||||
from alipay import AliPay
|
||||
from loguru import logger
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.security import decode_payment_token
|
||||
from app.repositories.orders import create_order, get_order_by_trade_no, update_order_status
|
||||
from app.repositories.users import update_user
|
||||
|
||||
# 支付宝网关地址
|
||||
ALIPAY_GATEWAY = "https://openapi.alipay.com/gateway.do"
|
||||
ALIPAY_GATEWAY_SANDBOX = "https://openapi-sandbox.dl.alipaydev.com/gateway.do"
|
||||
|
||||
|
||||
def _get_alipay_client() -> AliPay:
|
||||
"""延迟初始化 Alipay 客户端"""
|
||||
return AliPay(
|
||||
appid=settings.ALIPAY_APP_ID,
|
||||
app_notify_url=settings.ALIPAY_NOTIFY_URL,
|
||||
app_private_key_string=open(settings.ALIPAY_PRIVATE_KEY_PATH).read(),
|
||||
alipay_public_key_string=open(settings.ALIPAY_PUBLIC_KEY_PATH).read(),
|
||||
sign_type="RSA2",
|
||||
debug=settings.ALIPAY_SANDBOX,
|
||||
)
|
||||
|
||||
|
||||
def _create_page_pay_url(out_trade_no: str, amount: float, subject: str) -> str | None:
|
||||
"""调用 alipay.trade.page.pay,返回支付宝收银台 URL"""
|
||||
client = _get_alipay_client()
|
||||
order_string = client.api_alipay_trade_page_pay(
|
||||
subject=subject,
|
||||
out_trade_no=out_trade_no,
|
||||
total_amount=amount,
|
||||
return_url=settings.ALIPAY_RETURN_URL,
|
||||
)
|
||||
if not order_string:
|
||||
logger.error(f"电脑网站支付下单失败: {out_trade_no}")
|
||||
return None
|
||||
|
||||
gateway = ALIPAY_GATEWAY_SANDBOX if settings.ALIPAY_SANDBOX else ALIPAY_GATEWAY
|
||||
pay_url = f"{gateway}?{order_string}"
|
||||
logger.info(f"电脑网站支付下单成功: {out_trade_no}")
|
||||
return pay_url
|
||||
|
||||
|
||||
def _verify_signature(data: dict, signature: str) -> bool:
|
||||
"""验证支付宝异步通知签名"""
|
||||
client = _get_alipay_client()
|
||||
return client.verify(data, signature)
|
||||
|
||||
|
||||
def create_payment_order(payment_token: str) -> dict:
|
||||
"""
|
||||
创建支付订单完整流程
|
||||
|
||||
Returns: {"pay_url": str, "out_trade_no": str, "amount": float}
|
||||
Raises: ValueError (token 无效), RuntimeError (API 失败)
|
||||
"""
|
||||
user_id = decode_payment_token(payment_token)
|
||||
if not user_id:
|
||||
raise ValueError("付费凭证无效或已过期,请重新登录")
|
||||
|
||||
out_trade_no = f"VG_{int(datetime.now().timestamp())}_{uuid.uuid4().hex[:8]}"
|
||||
amount = settings.PAYMENT_AMOUNT
|
||||
|
||||
create_order(user_id, out_trade_no, amount)
|
||||
|
||||
pay_url = _create_page_pay_url(out_trade_no, amount, "IPAgent 会员开通")
|
||||
if not pay_url:
|
||||
raise RuntimeError("创建支付订单失败,请稍后重试")
|
||||
|
||||
logger.info(f"用户 {user_id} 创建支付订单: {out_trade_no}")
|
||||
|
||||
return {"pay_url": pay_url, "out_trade_no": out_trade_no, "amount": amount}
|
||||
|
||||
|
||||
def handle_payment_notify(form_data: dict) -> bool:
|
||||
"""
|
||||
处理支付宝异步通知完整流程
|
||||
|
||||
Returns: True=验签通过, False=验签失败
|
||||
"""
|
||||
data = dict(form_data)
|
||||
|
||||
signature = data.pop("sign", "")
|
||||
data.pop("sign_type", None)
|
||||
|
||||
if not _verify_signature(data, signature):
|
||||
logger.warning(f"支付宝通知验签失败: {data.get('out_trade_no')}")
|
||||
return False
|
||||
|
||||
out_trade_no = data.get("out_trade_no", "")
|
||||
trade_status = data.get("trade_status", "")
|
||||
trade_no = data.get("trade_no", "")
|
||||
|
||||
logger.info(f"收到支付宝通知: {out_trade_no}, status={trade_status}, trade_no={trade_no}")
|
||||
|
||||
if trade_status not in ("TRADE_SUCCESS", "TRADE_FINISHED"):
|
||||
return True
|
||||
|
||||
order = get_order_by_trade_no(out_trade_no)
|
||||
if not order:
|
||||
logger.warning(f"订单不存在: {out_trade_no}")
|
||||
return True
|
||||
|
||||
if order["status"] == "paid":
|
||||
logger.info(f"订单已处理过: {out_trade_no}")
|
||||
return True
|
||||
|
||||
update_order_status(out_trade_no, "paid", trade_no)
|
||||
|
||||
user_id = order["user_id"]
|
||||
expires_at = (datetime.now(timezone.utc) + timedelta(days=settings.PAYMENT_EXPIRE_DAYS)).isoformat()
|
||||
update_user(user_id, {
|
||||
"is_active": True,
|
||||
"role": "user",
|
||||
"expires_at": expires_at,
|
||||
})
|
||||
|
||||
logger.success(f"用户 {user_id} 支付成功,已激活,有效期至 {expires_at}")
|
||||
return True
|
||||
|
||||
|
||||
def get_order_status(out_trade_no: str) -> str | None:
|
||||
"""查询订单支付状态"""
|
||||
order = get_order_by_trade_no(out_trade_no)
|
||||
if not order:
|
||||
return None
|
||||
return order["status"]
|
||||
@@ -2,9 +2,11 @@ import re
|
||||
import os
|
||||
import time
|
||||
import json
|
||||
import hashlib
|
||||
import asyncio
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unicodedata
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
@@ -19,8 +21,16 @@ BUCKET_REF_AUDIOS = "ref-audios"
|
||||
|
||||
|
||||
def sanitize_filename(filename: str) -> str:
|
||||
"""清理文件名,移除特殊字符"""
|
||||
safe_name = re.sub(r'[<>:"/\\|?*\s]', '_', filename)
|
||||
"""清理文件名用于 Storage key(仅保留 ASCII 安全字符)。"""
|
||||
normalized = unicodedata.normalize("NFKD", filename)
|
||||
ascii_name = normalized.encode("ascii", "ignore").decode("ascii")
|
||||
safe_name = re.sub(r"[^A-Za-z0-9._-]+", "_", ascii_name).strip("._-")
|
||||
|
||||
# 纯中文/emoji 等场景会被清空,使用稳定哈希兜底,避免 InvalidKey
|
||||
if not safe_name:
|
||||
digest = hashlib.md5(filename.encode("utf-8")).hexdigest()[:12]
|
||||
safe_name = f"audio_{digest}"
|
||||
|
||||
if len(safe_name) > 50:
|
||||
ext = Path(safe_name).suffix
|
||||
safe_name = safe_name[:50 - len(ext)] + ext
|
||||
|
||||
@@ -13,11 +13,12 @@ router = APIRouter()
|
||||
async def extract_script_tool(
|
||||
file: Optional[UploadFile] = File(None),
|
||||
url: Optional[str] = Form(None),
|
||||
rewrite: bool = Form(True)
|
||||
rewrite: bool = Form(True),
|
||||
custom_prompt: Optional[str] = Form(None)
|
||||
):
|
||||
"""独立文案提取工具"""
|
||||
try:
|
||||
result = await service.extract_script(file=file, url=url, rewrite=rewrite)
|
||||
result = await service.extract_script(file=file, url=url, rewrite=rewrite, custom_prompt=custom_prompt)
|
||||
return success_response(result)
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, str(e))
|
||||
|
||||
@@ -17,9 +17,9 @@ from app.services.whisper_service import whisper_service
|
||||
from app.services.glm_service import glm_service
|
||||
|
||||
|
||||
async def extract_script(file=None, url: Optional[str] = None, rewrite: bool = True) -> dict:
|
||||
async def extract_script(file=None, url: Optional[str] = None, rewrite: bool = True, custom_prompt: Optional[str] = None) -> dict:
|
||||
"""
|
||||
文案提取:上传文件或视频链接 -> Whisper 转写 -> (可选) GLM 洗稿
|
||||
文案提取:上传文件或视频链接 -> Whisper 转写 -> (可选) GLM 改写
|
||||
"""
|
||||
if not file and not url:
|
||||
raise ValueError("必须提供文件或视频链接")
|
||||
@@ -63,11 +63,15 @@ async def extract_script(file=None, url: Optional[str] = None, rewrite: bool = T
|
||||
# 2. 提取文案 (Whisper)
|
||||
script = await whisper_service.transcribe(str(audio_path))
|
||||
|
||||
# 3. AI 洗稿 (GLM)
|
||||
# 3. AI 改写 (GLM) — 失败时降级返回原文
|
||||
rewritten = None
|
||||
if rewrite and script and len(script.strip()) > 0:
|
||||
logger.info("Rewriting script...")
|
||||
rewritten = await glm_service.rewrite_script(script)
|
||||
try:
|
||||
rewritten = await glm_service.rewrite_script(script, custom_prompt)
|
||||
except Exception as e:
|
||||
logger.warning(f"GLM rewrite failed, returning original script: {e}")
|
||||
rewritten = None
|
||||
|
||||
return {
|
||||
"original_script": script,
|
||||
@@ -156,125 +160,120 @@ def _download_yt_dlp(url_value: str, temp_dir: Path, timestamp: int) -> Path:
|
||||
'quiet': True,
|
||||
'no_warnings': True,
|
||||
'http_headers': {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
|
||||
'Referer': 'https://www.douyin.com/',
|
||||
}
|
||||
}
|
||||
|
||||
with yt_dlp.YoutubeDL() as ydl_raw:
|
||||
ydl: Any = ydl_raw
|
||||
ydl.params.update(ydl_opts)
|
||||
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||||
info = ydl.extract_info(url_value, download=True)
|
||||
if 'requested_downloads' in info:
|
||||
downloaded_file = info['requested_downloads'][0]['filepath']
|
||||
else:
|
||||
ext = info.get('ext', 'mp4')
|
||||
id = info.get('id')
|
||||
downloaded_file = str(temp_dir / f"tool_download_{timestamp}_{id}.{ext}")
|
||||
vid_id = info.get('id')
|
||||
downloaded_file = str(temp_dir / f"tool_download_{timestamp}_{vid_id}.{ext}")
|
||||
|
||||
return Path(downloaded_file)
|
||||
|
||||
|
||||
async def _download_douyin_manual(url: str, temp_dir: Path, timestamp: int) -> Optional[Path]:
|
||||
"""手动下载抖音视频 (Fallback)"""
|
||||
logger.info(f"[SuperIPAgent] Starting download for: {url}")
|
||||
"""手动下载抖音视频 (Fallback) — 通过移动端分享页获取播放地址"""
|
||||
logger.info(f"[douyin-fallback] Starting download for: {url}")
|
||||
|
||||
try:
|
||||
# 1. 解析短链接,提取视频 ID
|
||||
headers = {
|
||||
"user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
|
||||
"user-agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15"
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient(follow_redirects=True, timeout=10.0) as client:
|
||||
resp = await client.get(url, headers=headers)
|
||||
final_url = str(resp.url)
|
||||
|
||||
logger.info(f"[SuperIPAgent] Final URL: {final_url}")
|
||||
logger.info(f"[douyin-fallback] Final URL: {final_url}")
|
||||
|
||||
modal_id = None
|
||||
video_id = None
|
||||
match = re.search(r'/video/(\d+)', final_url)
|
||||
if match:
|
||||
modal_id = match.group(1)
|
||||
video_id = match.group(1)
|
||||
|
||||
if not modal_id:
|
||||
logger.error("[SuperIPAgent] Could not extract modal_id")
|
||||
if not video_id:
|
||||
logger.error("[douyin-fallback] Could not extract video_id")
|
||||
return None
|
||||
|
||||
logger.info(f"[SuperIPAgent] Extracted modal_id: {modal_id}")
|
||||
logger.info(f"[douyin-fallback] Extracted video_id: {video_id}")
|
||||
|
||||
target_url = f"https://www.douyin.com/user/MS4wLjABAAAAN_s_hups7LD0N4qnrM3o2gI0vuG3pozNaEolz2_py3cHTTrpVr1Z4dukFD9SOlwY?from_tab_name=main&modal_id={modal_id}"
|
||||
# 2. 获取新鲜 ttwid
|
||||
ttwid = ""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
ttwid_resp = await client.post(
|
||||
"https://ttwid.bytedance.com/ttwid/union/register/",
|
||||
json={
|
||||
"region": "cn", "aid": 6383, "needFid": False,
|
||||
"service": "www.douyin.com",
|
||||
"migrate_info": {"ticket": "", "source": "node"},
|
||||
"cbUrlProtocol": "https", "union": True,
|
||||
}
|
||||
)
|
||||
ttwid = ttwid_resp.cookies.get("ttwid", "")
|
||||
logger.info(f"[douyin-fallback] Got fresh ttwid (len={len(ttwid)})")
|
||||
except Exception as e:
|
||||
logger.warning(f"[douyin-fallback] Failed to get ttwid: {e}")
|
||||
|
||||
from app.core.config import settings
|
||||
if not settings.DOUYIN_COOKIE:
|
||||
logger.warning("[SuperIPAgent] DOUYIN_COOKIE 未配置,视频下载可能失败")
|
||||
|
||||
headers_with_cookie = {
|
||||
"accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
|
||||
"cookie": settings.DOUYIN_COOKIE,
|
||||
"user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
|
||||
# 3. 访问移动端分享页提取播放地址
|
||||
page_headers = {
|
||||
"user-agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15",
|
||||
"cookie": f"ttwid={ttwid}" if ttwid else "",
|
||||
}
|
||||
|
||||
logger.info(f"[SuperIPAgent] Requesting page with Cookie...")
|
||||
async with httpx.AsyncClient(follow_redirects=True, timeout=15.0) as client:
|
||||
page_resp = await client.get(
|
||||
f"https://m.douyin.com/share/video/{video_id}",
|
||||
headers=page_headers,
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.get(target_url, headers=headers_with_cookie)
|
||||
page_text = page_resp.text
|
||||
logger.info(f"[douyin-fallback] Mobile page length: {len(page_text)}")
|
||||
|
||||
content_match = re.findall(r'<script id="RENDER_DATA" type="application/json">(.*?)</script>', response.text)
|
||||
if not content_match:
|
||||
if "SSR_HYDRATED_DATA" in response.text:
|
||||
content_match = re.findall(r'<script id="SSR_HYDRATED_DATA" type="application/json">(.*?)</script>', response.text)
|
||||
|
||||
if not content_match:
|
||||
logger.error(f"[SuperIPAgent] Could not find RENDER_DATA in page (len={len(response.text)})")
|
||||
return None
|
||||
|
||||
content = unquote(content_match[0])
|
||||
try:
|
||||
data = json.loads(content)
|
||||
except:
|
||||
logger.error("[SuperIPAgent] JSON decode failed")
|
||||
return None
|
||||
|
||||
video_url = None
|
||||
try:
|
||||
if "app" in data and "videoDetail" in data["app"]:
|
||||
info = data["app"]["videoDetail"]["video"]
|
||||
if "bitRateList" in info and info["bitRateList"]:
|
||||
video_url = info["bitRateList"][0]["playAddr"][0]["src"]
|
||||
elif "playAddr" in info and info["playAddr"]:
|
||||
video_url = info["playAddr"][0]["src"]
|
||||
except Exception as e:
|
||||
logger.error(f"[SuperIPAgent] Path extraction failed: {e}")
|
||||
|
||||
if not video_url:
|
||||
logger.error("[SuperIPAgent] No video_url found")
|
||||
# 4. 提取 play_addr
|
||||
addr_match = re.search(
|
||||
r'"play_addr":\{"uri":"([^"]+)","url_list":\["([^"]+)"',
|
||||
page_text,
|
||||
)
|
||||
if not addr_match:
|
||||
logger.error("[douyin-fallback] Could not find play_addr in mobile page")
|
||||
return None
|
||||
|
||||
video_url = addr_match.group(2).replace(r"\u002F", "/")
|
||||
if video_url.startswith("//"):
|
||||
video_url = "https:" + video_url
|
||||
|
||||
logger.info(f"[SuperIPAgent] Found video URL: {video_url[:50]}...")
|
||||
logger.info(f"[douyin-fallback] Found video URL: {video_url[:80]}...")
|
||||
|
||||
# 5. 下载视频
|
||||
temp_path = temp_dir / f"douyin_manual_{timestamp}.mp4"
|
||||
download_headers = {
|
||||
'Referer': 'https://www.douyin.com/',
|
||||
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
|
||||
"Referer": "https://www.douyin.com/",
|
||||
"User-Agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15",
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||
async with httpx.AsyncClient(timeout=120.0, follow_redirects=True) as client:
|
||||
async with client.stream("GET", video_url, headers=download_headers) as dl_resp:
|
||||
if dl_resp.status_code == 200:
|
||||
with open(temp_path, 'wb') as f:
|
||||
with open(temp_path, "wb") as f:
|
||||
async for chunk in dl_resp.aiter_bytes(chunk_size=8192):
|
||||
f.write(chunk)
|
||||
|
||||
logger.info(f"[SuperIPAgent] Downloaded successfully: {temp_path}")
|
||||
logger.info(f"[douyin-fallback] Downloaded successfully: {temp_path}")
|
||||
return temp_path
|
||||
else:
|
||||
logger.error(f"[SuperIPAgent] Download failed: {dl_resp.status_code}")
|
||||
logger.error(f"[douyin-fallback] Download failed: {dl_resp.status_code}")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[SuperIPAgent] Logic failed: {e}")
|
||||
logger.error(f"[douyin-fallback] Logic failed: {e}")
|
||||
return None
|
||||
|
||||
|
||||
|
||||
@@ -1,17 +1,79 @@
|
||||
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
|
||||
from .service import list_generated_videos, delete_generated_video, delete_all_generated_videos
|
||||
from app.modules.generated_audios.service import delete_all_generated_audios
|
||||
from app.services.storage import storage_service
|
||||
|
||||
|
||||
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(
|
||||
@@ -53,12 +115,87 @@ async def voiceclone_health():
|
||||
return success_response(await get_voiceclone_health())
|
||||
|
||||
|
||||
@router.post("/cleanup")
|
||||
async def cleanup_workspace(current_user: dict = Depends(get_current_user)):
|
||||
user_id = current_user["id"]
|
||||
|
||||
videos_deleted, videos_failed = await delete_all_generated_videos(user_id)
|
||||
audios_deleted, audios_failed = await delete_all_generated_audios(user_id)
|
||||
|
||||
if videos_failed > 0 or audios_failed > 0:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=(
|
||||
f"工作区清理不完整:视频删除失败 {videos_failed} 个,"
|
||||
f"配音删除失败 {audios_failed} 个,请重试"
|
||||
),
|
||||
)
|
||||
|
||||
return success_response({
|
||||
"videos_deleted": videos_deleted,
|
||||
"audios_deleted": audios_deleted,
|
||||
}, message="工作区已清理")
|
||||
|
||||
|
||||
@router.get("/generated")
|
||||
async def list_generated(current_user: dict = Depends(get_current_user)):
|
||||
return success_response(await list_generated_videos(current_user["id"]))
|
||||
|
||||
|
||||
@router.get("/generated/{video_id}/download")
|
||||
async def download_generated(video_id: str, current_user: dict = Depends(get_current_user)):
|
||||
user_id = current_user["id"]
|
||||
storage_path = f"{user_id}/{video_id}.mp4"
|
||||
local_path = storage_service.get_local_file_path(
|
||||
bucket=storage_service.BUCKET_OUTPUTS,
|
||||
path=storage_path,
|
||||
)
|
||||
if not local_path or not os.path.exists(local_path):
|
||||
raise HTTPException(status_code=404, detail="视频文件不存在")
|
||||
return FileResponse(
|
||||
path=local_path,
|
||||
media_type="video/mp4",
|
||||
filename=f"{video_id}.mp4",
|
||||
headers={"Content-Disposition": f'attachment; filename="{video_id}.mp4"'},
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/generated/{video_id}")
|
||||
async def delete_generated(video_id: str, current_user: dict = Depends(get_current_user)):
|
||||
result = await delete_generated_video(current_user["id"], video_id)
|
||||
return success_response(result, message="视频已删除")
|
||||
|
||||
|
||||
@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
|
||||
|
||||
|
||||
@@ -26,6 +26,10 @@ class GenerateRequest(BaseModel):
|
||||
enable_subtitles: bool = True
|
||||
subtitle_style_id: Optional[str] = None
|
||||
title_style_id: Optional[str] = None
|
||||
secondary_title: Optional[str] = None
|
||||
secondary_title_style_id: Optional[str] = None
|
||||
secondary_title_font_size: Optional[int] = None
|
||||
secondary_title_top_margin: Optional[int] = None
|
||||
subtitle_font_size: Optional[int] = None
|
||||
title_font_size: Optional[int] = None
|
||||
title_top_margin: Optional[int] = None
|
||||
@@ -34,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)
|
||||
|
||||
@@ -73,6 +73,36 @@ async def list_generated_videos(user_id: str) -> dict:
|
||||
return {"videos": []}
|
||||
|
||||
|
||||
async def delete_all_generated_videos(user_id: str) -> tuple[int, int]:
|
||||
"""删除用户所有生成的视频,返回 (删除数量, 失败数量)"""
|
||||
try:
|
||||
files = await storage_service.list_files(
|
||||
bucket=storage_service.BUCKET_OUTPUTS,
|
||||
path=user_id,
|
||||
strict=True,
|
||||
)
|
||||
deleted_count = 0
|
||||
failed_count = 0
|
||||
for f in files:
|
||||
name = f.get("name")
|
||||
if not name or name == ".emptyFolderPlaceholder":
|
||||
continue
|
||||
full_path = f"{user_id}/{name}"
|
||||
try:
|
||||
await storage_service.delete_file(
|
||||
bucket=storage_service.BUCKET_OUTPUTS,
|
||||
path=full_path
|
||||
)
|
||||
deleted_count += 1
|
||||
except Exception as e:
|
||||
failed_count += 1
|
||||
logger.warning(f"Delete file failed: {full_path}, {e}")
|
||||
return deleted_count, failed_count
|
||||
except Exception as e:
|
||||
logger.error(f"Delete all generated videos failed: {e}")
|
||||
return 0, 1
|
||||
|
||||
|
||||
async def delete_generated_video(user_id: str, video_id: str) -> dict:
|
||||
"""删除生成的视频"""
|
||||
try:
|
||||
|
||||
@@ -54,7 +54,7 @@ class RedisTaskStore:
|
||||
"progress": 0,
|
||||
"user_id": user_id,
|
||||
}
|
||||
self._client.set(self._key(task_id), json.dumps(task, ensure_ascii=False))
|
||||
self._client.set(self._key(task_id), json.dumps(task, ensure_ascii=False), ex=86400)
|
||||
self._client.sadd(self._index_key, task_id)
|
||||
return task
|
||||
|
||||
@@ -71,12 +71,17 @@ class RedisTaskStore:
|
||||
keys = [self._key(task_id) for task_id in task_ids]
|
||||
raw_items = self._client.mget(keys)
|
||||
tasks = []
|
||||
for raw in raw_items:
|
||||
if raw:
|
||||
try:
|
||||
tasks.append(json.loads(raw))
|
||||
except Exception:
|
||||
continue
|
||||
expired = []
|
||||
for task_id, raw in zip(task_ids, raw_items):
|
||||
if raw is None:
|
||||
expired.append(task_id)
|
||||
continue
|
||||
try:
|
||||
tasks.append(json.loads(raw))
|
||||
except Exception:
|
||||
continue
|
||||
if expired:
|
||||
self._client.srem(self._index_key, *expired)
|
||||
return tasks
|
||||
|
||||
def update(self, task_id: str, updates: Dict[str, Any]) -> Dict[str, Any]:
|
||||
@@ -84,7 +89,8 @@ class RedisTaskStore:
|
||||
if task.get("status") == "not_found":
|
||||
task = {"status": "pending", "task_id": task_id}
|
||||
task.update(updates)
|
||||
self._client.set(self._key(task_id), json.dumps(task, ensure_ascii=False))
|
||||
ttl = 7200 if task.get("status") in ("completed", "failed") else 86400
|
||||
self._client.set(self._key(task_id), json.dumps(task, ensure_ascii=False), ex=ttl)
|
||||
self._client.sadd(self._index_key, task_id)
|
||||
return task
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from typing import Optional, Any, List
|
||||
from pathlib import Path
|
||||
import asyncio
|
||||
import time
|
||||
import traceback
|
||||
import httpx
|
||||
@@ -23,6 +24,9 @@ from app.services.remotion_service import remotion_service
|
||||
from .schemas import GenerateRequest
|
||||
from .task_store import task_store
|
||||
|
||||
# 全局并发限制:最多同时运行 2 个视频生成任务
|
||||
_generation_semaphore = asyncio.Semaphore(2)
|
||||
|
||||
|
||||
def _locale_to_whisper_lang(locale: str) -> str:
|
||||
"""'en-US' → 'en', 'zh-CN' → 'zh'"""
|
||||
@@ -90,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)
|
||||
|
||||
|
||||
# ── 多素材辅助函数 ──
|
||||
|
||||
|
||||
@@ -168,6 +178,12 @@ def _split_equal(segments: List[dict], material_paths: List[str]) -> List[dict]:
|
||||
|
||||
|
||||
async def process_video_generation(task_id: str, req: GenerateRequest, user_id: str):
|
||||
_update_task(task_id, message="排队中...")
|
||||
async with _generation_semaphore:
|
||||
await _process_video_generation_inner(task_id, req, user_id)
|
||||
|
||||
|
||||
async def _process_video_generation_inner(task_id: str, req: GenerateRequest, user_id: str):
|
||||
temp_files = []
|
||||
try:
|
||||
start_time = time.time()
|
||||
@@ -204,7 +220,8 @@ async def process_video_generation(task_id: str, req: GenerateRequest, user_id:
|
||||
|
||||
# 归一化旋转元数据(如 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),
|
||||
)
|
||||
@@ -282,6 +299,42 @@ async def process_video_generation(task_id: str, req: GenerateRequest, user_id:
|
||||
|
||||
captions_path = None
|
||||
|
||||
async def _whisper_and_split():
|
||||
"""Whisper 对齐 → _split_equal 均分素材(公共逻辑)"""
|
||||
_update_task(task_id, message="正在生成字幕 (Whisper)...")
|
||||
_captions_path = temp_dir / f"{task_id}_captions.json"
|
||||
temp_files.append(_captions_path)
|
||||
captions_data = None
|
||||
try:
|
||||
captions_data = await whisper_service.align(
|
||||
audio_path=str(audio_path),
|
||||
text=req.text,
|
||||
output_path=str(_captions_path),
|
||||
language=_locale_to_whisper_lang(req.language),
|
||||
original_text=req.text,
|
||||
)
|
||||
print(f"[Pipeline] Whisper alignment completed (multi-material)")
|
||||
except Exception as e:
|
||||
logger.warning(f"Whisper alignment failed: {e}")
|
||||
_captions_path = None
|
||||
|
||||
_update_task(task_id, progress=15, message="正在分配素材...")
|
||||
|
||||
if captions_data and captions_data.get("segments"):
|
||||
result = _split_equal(captions_data["segments"], material_paths)
|
||||
else:
|
||||
logger.warning("[MultiMat] Whisper 无数据,按时长均分")
|
||||
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)
|
||||
result = [
|
||||
{"material_path": material_paths[i], "start": i * seg_dur,
|
||||
"end": (i + 1) * seg_dur, "index": i}
|
||||
for i in range(len(material_paths))
|
||||
]
|
||||
return result, _captions_path
|
||||
|
||||
if is_multi:
|
||||
# ══════════════════════════════════════
|
||||
# 多素材流水线
|
||||
@@ -326,83 +379,13 @@ async def process_video_generation(task_id: str, req: GenerateRequest, user_id:
|
||||
f" 与素材数量({len(material_paths)})不一致,回退自动分配"
|
||||
)
|
||||
|
||||
# 原有逻辑:Whisper → _split_equal
|
||||
_update_task(task_id, message="正在生成字幕 (Whisper)...")
|
||||
|
||||
captions_path = temp_dir / f"{task_id}_captions.json"
|
||||
temp_files.append(captions_path)
|
||||
|
||||
try:
|
||||
captions_data = await whisper_service.align(
|
||||
audio_path=str(audio_path),
|
||||
text=req.text,
|
||||
output_path=str(captions_path),
|
||||
language=_locale_to_whisper_lang(req.language),
|
||||
original_text=req.text,
|
||||
)
|
||||
print(f"[Pipeline] Whisper alignment completed (multi-material)")
|
||||
except Exception as e:
|
||||
logger.warning(f"Whisper alignment failed: {e}")
|
||||
captions_data = None
|
||||
captions_path = None
|
||||
|
||||
_update_task(task_id, progress=15, message="正在分配素材...")
|
||||
|
||||
if captions_data and captions_data.get("segments"):
|
||||
assignments = _split_equal(captions_data["segments"], material_paths)
|
||||
else:
|
||||
# Whisper 失败 → 按时长均分(不依赖字符对齐)
|
||||
logger.warning("[MultiMat] Whisper 无数据,按时长均分")
|
||||
audio_dur = video._get_duration(str(audio_path))
|
||||
if audio_dur <= 0:
|
||||
audio_dur = 30.0 # 安全兜底
|
||||
seg_dur = audio_dur / len(material_paths)
|
||||
assignments = [
|
||||
{"material_path": material_paths[i], "start": i * seg_dur,
|
||||
"end": (i + 1) * seg_dur, "index": i}
|
||||
for i in range(len(material_paths))
|
||||
]
|
||||
assignments, captions_path = await _whisper_and_split()
|
||||
|
||||
else:
|
||||
# 原有逻辑:Whisper → _split_equal
|
||||
_update_task(task_id, message="正在生成字幕 (Whisper)...")
|
||||
|
||||
captions_path = temp_dir / f"{task_id}_captions.json"
|
||||
temp_files.append(captions_path)
|
||||
|
||||
try:
|
||||
captions_data = await whisper_service.align(
|
||||
audio_path=str(audio_path),
|
||||
text=req.text,
|
||||
output_path=str(captions_path),
|
||||
language=_locale_to_whisper_lang(req.language),
|
||||
original_text=req.text,
|
||||
)
|
||||
print(f"[Pipeline] Whisper alignment completed (multi-material)")
|
||||
except Exception as e:
|
||||
logger.warning(f"Whisper alignment failed: {e}")
|
||||
captions_data = None
|
||||
captions_path = None
|
||||
|
||||
_update_task(task_id, progress=15, message="正在分配素材...")
|
||||
|
||||
if captions_data and captions_data.get("segments"):
|
||||
assignments = _split_equal(captions_data["segments"], material_paths)
|
||||
else:
|
||||
# Whisper 失败 → 按时长均分(不依赖字符对齐)
|
||||
logger.warning("[MultiMat] Whisper 无数据,按时长均分")
|
||||
audio_dur = video._get_duration(str(audio_path))
|
||||
if audio_dur <= 0:
|
||||
audio_dur = 30.0 # 安全兜底
|
||||
seg_dur = audio_dur / len(material_paths)
|
||||
assignments = [
|
||||
{"material_path": material_paths[i], "start": i * seg_dur,
|
||||
"end": (i + 1) * seg_dur, "index": i}
|
||||
for i in range(len(material_paths))
|
||||
]
|
||||
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
|
||||
@@ -415,18 +398,19 @@ async def process_video_generation(task_id: str, req: GenerateRequest, user_id:
|
||||
|
||||
lipsync_start = time.time()
|
||||
|
||||
# ── 第一步:下载所有素材并检测分辨率 ──
|
||||
# ── 第一步:并行下载所有素材并检测分辨率 ──
|
||||
material_locals: List[Path] = []
|
||||
resolutions = []
|
||||
|
||||
for i, assignment in enumerate(assignments):
|
||||
async def _download_and_normalize(i: int, assignment: dict):
|
||||
"""下载单个素材并归一化方向"""
|
||||
material_local = temp_dir / f"{task_id}_material_{i}.mp4"
|
||||
temp_files.append(material_local)
|
||||
await _download_material(assignment["material_path"], material_local)
|
||||
|
||||
# 归一化旋转元数据,确保分辨率判断与后续推理一致
|
||||
normalized_material = temp_dir / f"{task_id}_material_{i}_norm.mp4"
|
||||
normalized_result = video.normalize_orientation(
|
||||
normalized_result = await _run_blocking(
|
||||
video.normalize_orientation,
|
||||
str(material_local),
|
||||
str(normalized_material),
|
||||
)
|
||||
@@ -434,8 +418,17 @@ async def process_video_generation(task_id: str, req: GenerateRequest, user_id:
|
||||
temp_files.append(normalized_material)
|
||||
material_local = normalized_material
|
||||
|
||||
material_locals.append(material_local)
|
||||
resolutions.append(video.get_resolution(str(material_local)))
|
||||
res = video.get_resolution(str(material_local))
|
||||
return material_local, res
|
||||
|
||||
download_tasks = [
|
||||
_download_and_normalize(i, assignment)
|
||||
for i, assignment in enumerate(assignments)
|
||||
]
|
||||
download_results = await asyncio.gather(*download_tasks)
|
||||
for local, res in download_results:
|
||||
material_locals.append(local)
|
||||
resolutions.append(res)
|
||||
|
||||
# 按用户选择的画面比例统一分辨率
|
||||
base_res = target_resolution
|
||||
@@ -443,38 +436,54 @@ async def process_video_generation(task_id: str, req: GenerateRequest, user_id:
|
||||
if need_scale:
|
||||
logger.info(f"[MultiMat] 素材分辨率不一致,统一到 {base_res[0]}x{base_res[1]}")
|
||||
|
||||
# ── 第二步:裁剪每段素材到对应时长 ──
|
||||
prepared_segments: List[Path] = []
|
||||
# ── 第二步:并行裁剪每段素材到对应时长 ──
|
||||
prepared_segments: List[Optional[Path]] = [None] * num_segments
|
||||
|
||||
for i, assignment in enumerate(assignments):
|
||||
seg_progress = 15 + int((i / num_segments) * 30) # 15% → 45%
|
||||
async def _prepare_one_segment(i: int, assignment: dict):
|
||||
"""将单个素材裁剪/循环到对应时长"""
|
||||
seg_dur = assignment["end"] - assignment["start"]
|
||||
_update_task(
|
||||
task_id,
|
||||
progress=seg_progress,
|
||||
message=f"正在准备素材 {i+1}/{num_segments}..."
|
||||
)
|
||||
|
||||
prepared_path = temp_dir / f"{task_id}_prepared_{i}.mp4"
|
||||
temp_files.append(prepared_path)
|
||||
video.prepare_segment(
|
||||
str(material_locals[i]), seg_dur, str(prepared_path),
|
||||
# 多素材拼接前统一重编码为同分辨率/同编码,避免 concat 仅保留首段
|
||||
target_resolution=base_res,
|
||||
source_start=assignment.get("source_start", 0.0),
|
||||
source_end=assignment.get("source_end"),
|
||||
target_fps=25,
|
||||
prepare_target_res = None if resolutions[i] == base_res else base_res
|
||||
|
||||
await _run_blocking(
|
||||
video.prepare_segment,
|
||||
str(material_locals[i]),
|
||||
seg_dur,
|
||||
str(prepared_path),
|
||||
prepare_target_res,
|
||||
assignment.get("source_start", 0.0),
|
||||
assignment.get("source_end"),
|
||||
25,
|
||||
)
|
||||
prepared_segments.append(prepared_path)
|
||||
return i, prepared_path
|
||||
|
||||
_update_task(
|
||||
task_id,
|
||||
progress=15,
|
||||
message=f"正在并行准备 {num_segments} 个素材片段..."
|
||||
)
|
||||
|
||||
prepare_tasks = [
|
||||
_prepare_one_segment(i, assignment)
|
||||
for i, assignment in enumerate(assignments)
|
||||
]
|
||||
prepare_results = await asyncio.gather(*prepare_tasks)
|
||||
for i, path in prepare_results:
|
||||
prepared_segments[i] = path
|
||||
|
||||
# ── 第二步:拼接所有素材片段 ──
|
||||
_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 推理 ──
|
||||
@@ -484,7 +493,12 @@ async def process_video_generation(task_id: str, req: GenerateRequest, user_id:
|
||||
_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
|
||||
@@ -518,18 +532,22 @@ async def process_video_generation(task_id: str, req: GenerateRequest, user_id:
|
||||
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
|
||||
|
||||
@@ -542,7 +560,18 @@ async def process_video_generation(task_id: str, req: GenerateRequest, user_id:
|
||||
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="唇形同步不可用,使用原始视频...")
|
||||
@@ -553,59 +582,99 @@ async def process_video_generation(task_id: str, req: GenerateRequest, user_id:
|
||||
print(f"[Pipeline] LipSync completed in {lipsync_time:.1f}s")
|
||||
_update_task(task_id, progress=80)
|
||||
|
||||
# 单素材模式:Whisper 在 LatentSync 之后
|
||||
if req.enable_subtitles:
|
||||
# 单素材模式:Whisper 延迟到下方与 BGM 并行执行
|
||||
if not req.enable_subtitles:
|
||||
captions_path = None
|
||||
|
||||
_update_task(task_id, progress=85)
|
||||
|
||||
# ── Whisper 字幕 + BGM 混音 并行(两者都只依赖 audio_path)──
|
||||
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
|
||||
if need_whisper:
|
||||
captions_path = temp_dir / f"{task_id}_captions.json"
|
||||
temp_files.append(captions_path)
|
||||
_captions_path_str = str(captions_path)
|
||||
|
||||
async def _run_whisper():
|
||||
_update_task(task_id, message="正在生成字幕 (Whisper)...", progress=82)
|
||||
|
||||
captions_path = temp_dir / f"{task_id}_captions.json"
|
||||
temp_files.append(captions_path)
|
||||
|
||||
try:
|
||||
await whisper_service.align(
|
||||
audio_path=str(audio_path),
|
||||
text=req.text,
|
||||
output_path=str(captions_path),
|
||||
output_path=_captions_path_str,
|
||||
language=_locale_to_whisper_lang(req.language),
|
||||
original_text=req.text,
|
||||
)
|
||||
print(f"[Pipeline] Whisper alignment completed")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning(f"Whisper alignment failed, skipping subtitles: {e}")
|
||||
captions_path = None
|
||||
return False
|
||||
|
||||
_update_task(task_id, progress=85)
|
||||
_whisper_task = _run_whisper()
|
||||
|
||||
final_audio_path = audio_path
|
||||
if req.bgm_id:
|
||||
_update_task(task_id, message="正在合成背景音乐...", progress=86)
|
||||
|
||||
bgm_path = resolve_bgm_path(req.bgm_id)
|
||||
if bgm_path:
|
||||
mix_output_path = temp_dir / f"{task_id}_audio_mix.wav"
|
||||
temp_files.append(mix_output_path)
|
||||
volume = req.bgm_volume if req.bgm_volume is not None else 0.2
|
||||
volume = max(0.0, min(float(volume), 1.0))
|
||||
try:
|
||||
video.mix_audio(
|
||||
voice_path=str(audio_path),
|
||||
bgm_path=str(bgm_path),
|
||||
output_path=str(mix_output_path),
|
||||
bgm_volume=volume
|
||||
)
|
||||
final_audio_path = mix_output_path
|
||||
except Exception as e:
|
||||
logger.warning(f"BGM mix failed, fallback to voice only: {e}")
|
||||
_mix_output = str(mix_output_path)
|
||||
_bgm_path = str(bgm_path)
|
||||
_voice_path = str(audio_path)
|
||||
_volume = volume
|
||||
|
||||
async def _run_bgm():
|
||||
_update_task(task_id, message="正在合成背景音乐...", progress=86)
|
||||
try:
|
||||
await _run_blocking(
|
||||
video.mix_audio,
|
||||
_voice_path,
|
||||
_bgm_path,
|
||||
_mix_output,
|
||||
_volume,
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning(f"BGM mix failed, fallback to voice only: {e}")
|
||||
return False
|
||||
|
||||
_bgm_task = _run_bgm()
|
||||
else:
|
||||
logger.warning(f"BGM not found: {req.bgm_id}")
|
||||
|
||||
use_remotion = (captions_path and captions_path.exists()) or req.title
|
||||
# 并行等待 Whisper + BGM
|
||||
parallel_tasks = [t for t in (_whisper_task, _bgm_task) if t is not None]
|
||||
if parallel_tasks:
|
||||
results = await asyncio.gather(*parallel_tasks)
|
||||
result_idx = 0
|
||||
if _whisper_task is not None:
|
||||
if not results[result_idx]:
|
||||
captions_path = None
|
||||
result_idx += 1
|
||||
if _bgm_task is not None:
|
||||
if results[result_idx] and mix_output_path is not None:
|
||||
final_audio_path = mix_output_path
|
||||
|
||||
|
||||
use_remotion = (captions_path and captions_path.exists()) or req.title or req.secondary_title
|
||||
|
||||
subtitle_style = None
|
||||
title_style = None
|
||||
secondary_title_style = None
|
||||
if req.enable_subtitles:
|
||||
subtitle_style = get_style("subtitle", req.subtitle_style_id) or get_default_style("subtitle")
|
||||
if req.title:
|
||||
title_style = get_style("title", req.title_style_id) or get_default_style("title")
|
||||
if req.secondary_title:
|
||||
secondary_title_style = get_style("title", req.secondary_title_style_id) or get_default_style("title")
|
||||
|
||||
if req.subtitle_font_size and req.enable_subtitles:
|
||||
if subtitle_style is None:
|
||||
@@ -627,6 +696,16 @@ async def process_video_generation(task_id: str, req: GenerateRequest, user_id:
|
||||
subtitle_style = {}
|
||||
subtitle_style["bottom_margin"] = int(req.subtitle_bottom_margin)
|
||||
|
||||
if req.secondary_title_font_size and req.secondary_title:
|
||||
if secondary_title_style is None:
|
||||
secondary_title_style = {}
|
||||
secondary_title_style["font_size"] = int(req.secondary_title_font_size)
|
||||
|
||||
if req.secondary_title_top_margin is not None and req.secondary_title:
|
||||
if secondary_title_style is None:
|
||||
secondary_title_style = {}
|
||||
secondary_title_style["top_margin"] = int(req.secondary_title_top_margin)
|
||||
|
||||
if use_remotion:
|
||||
subtitle_style = prepare_style_for_remotion(
|
||||
subtitle_style,
|
||||
@@ -638,17 +717,34 @@ async def process_video_generation(task_id: str, req: GenerateRequest, user_id:
|
||||
temp_dir,
|
||||
f"{task_id}_title_font"
|
||||
)
|
||||
secondary_title_style = prepare_style_for_remotion(
|
||||
secondary_title_style,
|
||||
temp_dir,
|
||||
f"{task_id}_secondary_title_font"
|
||||
)
|
||||
|
||||
# 清理字体临时文件
|
||||
for prefix in [f"{task_id}_subtitle_font", f"{task_id}_title_font", f"{task_id}_secondary_title_font"]:
|
||||
for ext in [".ttf", ".otf", ".woff", ".woff2"]:
|
||||
font_tmp = temp_dir / f"{prefix}{ext}"
|
||||
if font_tmp.exists():
|
||||
temp_files.append(font_tmp)
|
||||
|
||||
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"):
|
||||
@@ -665,7 +761,7 @@ async def process_video_generation(task_id: str, req: GenerateRequest, user_id:
|
||||
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,
|
||||
@@ -675,21 +771,26 @@ async def process_video_generation(task_id: str, req: GenerateRequest, user_id:
|
||||
enable_subtitles=req.enable_subtitles,
|
||||
subtitle_style=subtitle_style,
|
||||
title_style=title_style,
|
||||
secondary_title=req.secondary_title,
|
||||
secondary_title_style=secondary_title_style,
|
||||
on_progress=on_remotion_progress
|
||||
)
|
||||
print(f"[Pipeline] Remotion render completed")
|
||||
except Exception as e:
|
||||
logger.warning(f"Remotion render failed, using FFmpeg fallback: {e}")
|
||||
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
|
||||
|
||||
|
||||
34
backend/app/repositories/orders.py
Normal file
34
backend/app/repositories/orders.py
Normal file
@@ -0,0 +1,34 @@
|
||||
"""
|
||||
订单数据访问层
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, Optional, cast
|
||||
|
||||
from app.core.supabase import get_supabase
|
||||
|
||||
|
||||
def create_order(user_id: str, out_trade_no: str, amount: float) -> Dict[str, Any]:
|
||||
supabase = get_supabase()
|
||||
result = supabase.table("orders").insert({
|
||||
"user_id": user_id,
|
||||
"out_trade_no": out_trade_no,
|
||||
"amount": amount,
|
||||
"status": "pending",
|
||||
}).execute()
|
||||
return cast(Dict[str, Any], (result.data or [{}])[0])
|
||||
|
||||
|
||||
def get_order_by_trade_no(out_trade_no: str) -> Optional[Dict[str, Any]]:
|
||||
supabase = get_supabase()
|
||||
result = supabase.table("orders").select("*").eq("out_trade_no", out_trade_no).single().execute()
|
||||
return cast(Optional[Dict[str, Any]], result.data or None)
|
||||
|
||||
|
||||
def update_order_status(out_trade_no: str, status: str, trade_no: str | None = None) -> None:
|
||||
supabase = get_supabase()
|
||||
payload: Dict[str, Any] = {"status": status}
|
||||
if trade_no:
|
||||
payload["trade_no"] = trade_no
|
||||
if status == "paid":
|
||||
payload["paid_at"] = datetime.now(timezone.utc).isoformat()
|
||||
supabase.table("orders").update(payload).eq("out_trade_no", out_trade_no).execute()
|
||||
@@ -35,18 +35,19 @@ class GLMService:
|
||||
Returns:
|
||||
{"title": "标题", "tags": ["标签1", "标签2", ...]}
|
||||
"""
|
||||
prompt = f"""根据以下口播文案,生成一个吸引人的短视频标题和3个相关标签。
|
||||
prompt = f"""根据以下口播文案,生成一个吸引人的短视频标题、副标题和3个相关标签。
|
||||
|
||||
口播文案:
|
||||
{text}
|
||||
|
||||
要求:
|
||||
1. 标题要简洁有力,能吸引观众点击,不超过10个字
|
||||
2. 标签要与内容相关,便于搜索和推荐,只要3个
|
||||
3. 标题和标签必须使用与口播文案相同的语言(如文案是英文就用英文,日文就用日文)
|
||||
2. 副标题是对标题的补充说明或描述性文字,不超过20个字
|
||||
3. 标签要与内容相关,便于搜索和推荐,只要3个
|
||||
4. 标题、副标题和标签必须使用与口播文案相同的语言(如文案是英文就用英文,日文就用日文)
|
||||
|
||||
请严格按以下JSON格式返回(不要包含其他内容):
|
||||
{{"title": "标题", "tags": ["标签1", "标签2", "标签3"]}}"""
|
||||
{{"title": "标题", "secondary_title": "副标题", "tags": ["标签1", "标签2", "标签3"]}}"""
|
||||
|
||||
try:
|
||||
client = self._get_client()
|
||||
@@ -75,17 +76,24 @@ class GLMService:
|
||||
logger.error(f"GLM service error: {e}")
|
||||
raise Exception(f"AI 生成失败: {str(e)}")
|
||||
|
||||
async def rewrite_script(self, text: str) -> str:
|
||||
async def rewrite_script(self, text: str, custom_prompt: str = None) -> str:
|
||||
"""
|
||||
AI 洗稿(文案改写)
|
||||
AI 改写文案
|
||||
|
||||
Args:
|
||||
text: 原始文案
|
||||
custom_prompt: 自定义提示词,为空则使用默认提示词
|
||||
|
||||
Returns:
|
||||
改写后的文案
|
||||
"""
|
||||
prompt = f"""请将以下视频文案进行改写。
|
||||
if custom_prompt and custom_prompt.strip():
|
||||
prompt = f"""{custom_prompt.strip()}
|
||||
|
||||
原始文案:
|
||||
{text}"""
|
||||
else:
|
||||
prompt = f"""请将以下视频文案进行改写。
|
||||
|
||||
原始文案:
|
||||
{text}
|
||||
@@ -174,6 +182,8 @@ class GLMService:
|
||||
|
||||
# 尝试提取 JSON 块
|
||||
json_match = re.search(r'\{[^{}]*"title"[^{}]*"tags"[^{}]*\}', content, re.DOTALL)
|
||||
if not json_match:
|
||||
json_match = re.search(r'\{[^{}]*"title"[^{}]*"secondary_title"[^{}]*"tags"[^{}]*\}', content, re.DOTALL)
|
||||
if json_match:
|
||||
try:
|
||||
return json.loads(json_match.group())
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
唇形同步服务
|
||||
通过 subprocess 调用 LatentSync conda 环境进行推理
|
||||
配置为使用 GPU1 (CUDA:1)
|
||||
混合方案: 短视频用 LatentSync (高质量), 长视频用 MuseTalk (高速度)
|
||||
路由阈值: LIPSYNC_DURATION_THRESHOLD (默认 120s)
|
||||
"""
|
||||
import os
|
||||
import shutil
|
||||
@@ -11,21 +11,24 @@ 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:
|
||||
"""唇形同步服务 - LatentSync 1.6 集成 (Subprocess 方式)"""
|
||||
|
||||
class LipSyncService:
|
||||
"""唇形同步服务 - LatentSync 1.6 + MuseTalk 1.5 混合方案"""
|
||||
|
||||
def __init__(self):
|
||||
self.use_local = settings.LATENTSYNC_LOCAL
|
||||
self.api_url = settings.LATENTSYNC_API_URL
|
||||
self.latentsync_dir = settings.LATENTSYNC_DIR
|
||||
self.gpu_id = settings.LATENTSYNC_GPU_ID
|
||||
self.use_server = settings.LATENTSYNC_USE_SERVER
|
||||
|
||||
|
||||
# MuseTalk 配置
|
||||
self.musetalk_api_url = settings.MUSETALK_API_URL
|
||||
|
||||
# GPU 并发锁 (Serial Queue)
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
@@ -103,7 +106,7 @@ class LipSyncService:
|
||||
"-t", str(target_duration), # 截取到目标时长
|
||||
"-c:v", "libx264",
|
||||
"-preset", "fast",
|
||||
"-crf", "18",
|
||||
"-crf", "23",
|
||||
"-an", # 去掉原音频
|
||||
output_path
|
||||
]
|
||||
@@ -118,139 +121,43 @@ class LipSyncService:
|
||||
logger.warning(f"⚠️ 视频循环异常: {e}")
|
||||
return video_path
|
||||
|
||||
def _preprocess_video(self, video_path: str, output_path: str, target_height: int = 720) -> str:
|
||||
"""
|
||||
视频预处理:压缩视频以加速后续处理
|
||||
- 限制最大高度为 target_height (默认720p)
|
||||
- 保持宽高比
|
||||
- 使用快速编码预设
|
||||
|
||||
Returns: 预处理后的视频路径
|
||||
"""
|
||||
import subprocess
|
||||
import json
|
||||
|
||||
# 获取视频信息 (使用 JSON 格式更可靠)
|
||||
probe_cmd = [
|
||||
"ffprobe", "-v", "error",
|
||||
"-select_streams", "v:0",
|
||||
"-show_entries", "stream=height,width",
|
||||
"-of", "json",
|
||||
video_path
|
||||
]
|
||||
try:
|
||||
result = subprocess.run(probe_cmd, capture_output=True, text=True, timeout=10)
|
||||
if result.returncode != 0:
|
||||
logger.warning(f"⚠️ ffprobe 失败: {result.stderr[:100]}")
|
||||
return video_path
|
||||
|
||||
probe_data = json.loads(result.stdout)
|
||||
streams = probe_data.get("streams", [])
|
||||
if not streams:
|
||||
logger.warning("⚠️ 无法获取视频流信息,跳过预处理")
|
||||
return video_path
|
||||
|
||||
current_height = streams[0].get("height", 0)
|
||||
current_width = streams[0].get("width", 0)
|
||||
|
||||
if current_height == 0:
|
||||
logger.warning("⚠️ 视频高度为 0,跳过预处理")
|
||||
return video_path
|
||||
|
||||
logger.info(f"📹 原始视频分辨率: {current_width}×{current_height}")
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
logger.warning(f"⚠️ ffprobe 输出解析失败: {e}")
|
||||
return video_path
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.warning("⚠️ ffprobe 超时,跳过预处理")
|
||||
return video_path
|
||||
except Exception as e:
|
||||
logger.warning(f"⚠️ 获取视频信息失败: {e}")
|
||||
return video_path
|
||||
|
||||
# 如果视频已经足够小,跳过压缩
|
||||
if current_height <= target_height:
|
||||
logger.info(f"📹 视频高度 {current_height}p <= {target_height}p,无需压缩")
|
||||
return video_path
|
||||
|
||||
logger.info(f"📹 预处理视频: {current_height}p → {target_height}p")
|
||||
|
||||
# 使用 FFmpeg 压缩
|
||||
compress_cmd = [
|
||||
"ffmpeg", "-y",
|
||||
"-i", video_path,
|
||||
"-vf", f"scale=-2:{target_height}", # 保持宽高比,高度设为 target_height
|
||||
"-c:v", "libx264",
|
||||
"-preset", "ultrafast", # 最快编码速度
|
||||
"-crf", "23", # 质量因子
|
||||
"-c:a", "copy", # 音频直接复制
|
||||
output_path
|
||||
]
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
compress_cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120 # 增加超时时间到2分钟
|
||||
)
|
||||
if result.returncode == 0 and Path(output_path).exists():
|
||||
original_size = Path(video_path).stat().st_size / 1024 / 1024
|
||||
new_size = Path(output_path).stat().st_size / 1024 / 1024
|
||||
logger.info(f"✅ 视频压缩完成: {original_size:.1f}MB → {new_size:.1f}MB")
|
||||
return output_path
|
||||
else:
|
||||
logger.warning(f"⚠️ 视频压缩失败: {result.stderr[:200]}")
|
||||
return video_path
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.warning("⚠️ 视频压缩超时,使用原始视频")
|
||||
return video_path
|
||||
except Exception as e:
|
||||
logger.warning(f"⚠️ 视频压缩异常: {e}")
|
||||
return video_path
|
||||
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 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 _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)
|
||||
@@ -265,12 +172,53 @@ class LipSyncService:
|
||||
str(looped_video),
|
||||
audio_duration
|
||||
)
|
||||
else:
|
||||
actual_video_path = video_path
|
||||
|
||||
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)...")
|
||||
|
||||
@@ -352,6 +300,55 @@ class LipSyncService:
|
||||
shutil.copy(video_path, output_path)
|
||||
return output_path
|
||||
|
||||
async def _call_musetalk_server(
|
||||
self, video_path: str, audio_path: str, output_path: str
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
调用 MuseTalk 常驻服务。
|
||||
成功返回 output_path,不可用返回 None(信号上层回退到 LatentSync)。
|
||||
"""
|
||||
server_url = self.musetalk_api_url
|
||||
logger.info(f"⚡ 调用 MuseTalk 服务: {server_url}")
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=3600.0) as client:
|
||||
# 健康检查
|
||||
try:
|
||||
resp = await client.get(f"{server_url}/health", timeout=5.0)
|
||||
if resp.status_code != 200:
|
||||
logger.warning("⚠️ MuseTalk 健康检查失败")
|
||||
return None
|
||||
health = resp.json()
|
||||
if not health.get("model_loaded"):
|
||||
logger.warning("⚠️ MuseTalk 模型未加载")
|
||||
return None
|
||||
except Exception:
|
||||
logger.warning("⚠️ 无法连接 MuseTalk 服务")
|
||||
return None
|
||||
|
||||
# 发送推理请求
|
||||
payload = {
|
||||
"video_path": str(Path(video_path).resolve()),
|
||||
"audio_path": str(Path(audio_path).resolve()),
|
||||
"video_out_path": str(Path(output_path).resolve()),
|
||||
"batch_size": settings.MUSETALK_BATCH_SIZE,
|
||||
}
|
||||
|
||||
response = await client.post(f"{server_url}/lipsync", json=payload)
|
||||
|
||||
if response.status_code == 200:
|
||||
result = response.json()
|
||||
if Path(result["output_path"]).exists():
|
||||
logger.info(f"✅ MuseTalk 推理完成: {output_path}")
|
||||
return output_path
|
||||
|
||||
logger.error(f"❌ MuseTalk 服务报错: {response.text}")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ MuseTalk 调用失败: {e}")
|
||||
return None
|
||||
|
||||
async def _call_persistent_server(self, video_path: str, audio_path: str, output_path: str) -> str:
|
||||
"""调用本地常驻服务 (server.py)"""
|
||||
server_url = "http://localhost:8007"
|
||||
@@ -369,7 +366,7 @@ class LipSyncService:
|
||||
}
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=1200.0) as client:
|
||||
async with httpx.AsyncClient(timeout=3600.0) as client:
|
||||
# 先检查健康状态
|
||||
try:
|
||||
resp = await client.get(f"{server_url}/health", timeout=5.0)
|
||||
@@ -416,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:
|
||||
@@ -477,8 +477,18 @@ class LipSyncService:
|
||||
except:
|
||||
pass
|
||||
|
||||
# 检查 MuseTalk 服务
|
||||
musetalk_ready = False
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
resp = await client.get(f"{self.musetalk_api_url}/health")
|
||||
if resp.status_code == 200:
|
||||
musetalk_ready = resp.json().get("model_loaded", False)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {
|
||||
"model": "LatentSync 1.6",
|
||||
"model": "LatentSync 1.6 + MuseTalk 1.5",
|
||||
"conda_env": conda_ok,
|
||||
"weights": weights_ok,
|
||||
"gpu": gpu_ok,
|
||||
@@ -486,5 +496,7 @@ class LipSyncService:
|
||||
"gpu_id": self.gpu_id,
|
||||
"inference_steps": settings.LATENTSYNC_INFERENCE_STEPS,
|
||||
"guidance_scale": settings.LATENTSYNC_GUIDANCE_SCALE,
|
||||
"ready": conda_ok and weights_ok and gpu_ok
|
||||
"ready": conda_ok and weights_ok and gpu_ok,
|
||||
"musetalk_ready": musetalk_ready,
|
||||
"lipsync_threshold": settings.LIPSYNC_DURATION_THRESHOLD,
|
||||
}
|
||||
|
||||
@@ -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...")
|
||||
|
||||
@@ -36,6 +36,8 @@ class RemotionService:
|
||||
enable_subtitles: bool = True,
|
||||
subtitle_style: Optional[dict] = None,
|
||||
title_style: Optional[dict] = None,
|
||||
secondary_title: Optional[str] = None,
|
||||
secondary_title_style: Optional[dict] = None,
|
||||
on_progress: Optional[Callable[[int], None]] = None
|
||||
) -> str:
|
||||
"""
|
||||
@@ -69,7 +71,8 @@ class RemotionService:
|
||||
"--video", str(video_path),
|
||||
"--output", str(output_path),
|
||||
"--fps", str(fps),
|
||||
"--enableSubtitles", str(enable_subtitles).lower()
|
||||
"--enableSubtitles", str(enable_subtitles).lower(),
|
||||
"--concurrency", "4"
|
||||
])
|
||||
|
||||
if captions_path:
|
||||
@@ -86,6 +89,12 @@ class RemotionService:
|
||||
if title_style:
|
||||
cmd.extend(["--titleStyle", json.dumps(title_style, ensure_ascii=False)])
|
||||
|
||||
if secondary_title:
|
||||
cmd.extend(["--secondaryTitle", secondary_title])
|
||||
|
||||
if secondary_title_style:
|
||||
cmd.extend(["--secondaryTitleStyle", json.dumps(secondary_title_style, ensure_ascii=False)])
|
||||
|
||||
logger.info(f"Running Remotion render: {' '.join(cmd)}")
|
||||
|
||||
# 在线程池中运行子进程
|
||||
|
||||
@@ -182,18 +182,18 @@ class StorageService:
|
||||
logger.error(f"Get public URL failed: {e}")
|
||||
return ""
|
||||
|
||||
async def delete_file(self, bucket: str, path: str):
|
||||
"""异步删除文件"""
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
await loop.run_in_executor(
|
||||
None,
|
||||
lambda: self.supabase.storage.from_(bucket).remove([path])
|
||||
)
|
||||
logger.info(f"Deleted file: {bucket}/{path}")
|
||||
except Exception as e:
|
||||
logger.error(f"Delete file failed: {e}")
|
||||
pass
|
||||
async def delete_file(self, bucket: str, path: str):
|
||||
"""异步删除文件"""
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
await loop.run_in_executor(
|
||||
None,
|
||||
lambda: self.supabase.storage.from_(bucket).remove([path])
|
||||
)
|
||||
logger.info(f"Deleted file: {bucket}/{path}")
|
||||
except Exception as e:
|
||||
logger.error(f"Delete file failed: {e}")
|
||||
raise e
|
||||
|
||||
async def move_file(self, bucket: str, from_path: str, to_path: str):
|
||||
"""异步移动/重命名文件"""
|
||||
@@ -208,17 +208,19 @@ class StorageService:
|
||||
logger.error(f"Move file failed: {e}")
|
||||
raise e
|
||||
|
||||
async def list_files(self, bucket: str, path: str) -> List[Any]:
|
||||
"""异步列出文件"""
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
res = await loop.run_in_executor(
|
||||
None,
|
||||
lambda: self.supabase.storage.from_(bucket).list(path)
|
||||
)
|
||||
return res or []
|
||||
except Exception as e:
|
||||
logger.error(f"List files failed: {e}")
|
||||
return []
|
||||
async def list_files(self, bucket: str, path: str, strict: bool = False) -> List[Any]:
|
||||
"""异步列出文件"""
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
res = await loop.run_in_executor(
|
||||
None,
|
||||
lambda: self.supabase.storage.from_(bucket).list(path)
|
||||
)
|
||||
return res or []
|
||||
except Exception as e:
|
||||
logger.error(f"List files failed: {e}")
|
||||
if strict:
|
||||
raise e
|
||||
return []
|
||||
|
||||
storage_service = StorageService()
|
||||
|
||||
@@ -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,14 +1,15 @@
|
||||
"""
|
||||
视频合成服务
|
||||
"""
|
||||
import os
|
||||
import subprocess
|
||||
import json
|
||||
import shlex
|
||||
from pathlib import Path
|
||||
from loguru import logger
|
||||
from typing import Optional
|
||||
|
||||
"""
|
||||
视频合成服务
|
||||
"""
|
||||
import asyncio
|
||||
import os
|
||||
import subprocess
|
||||
import json
|
||||
import shlex
|
||||
from pathlib import Path
|
||||
from loguru import logger
|
||||
from typing import Optional
|
||||
|
||||
class VideoService:
|
||||
def __init__(self):
|
||||
pass
|
||||
@@ -113,146 +114,155 @@ class VideoService:
|
||||
|
||||
logger.warning("视频方向归一化失败,回退使用原视频")
|
||||
return video_path
|
||||
|
||||
def _run_ffmpeg(self, cmd: list) -> bool:
|
||||
cmd_str = ' '.join(shlex.quote(str(c)) for c in cmd)
|
||||
logger.debug(f"FFmpeg CMD: {cmd_str}")
|
||||
try:
|
||||
# Synchronous call for BackgroundTasks compatibility
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
shell=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding='utf-8',
|
||||
)
|
||||
if result.returncode != 0:
|
||||
logger.error(f"FFmpeg Error: {result.stderr}")
|
||||
return False
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"FFmpeg Exception: {e}")
|
||||
return False
|
||||
|
||||
def _get_duration(self, file_path: str) -> float:
|
||||
# Synchronous call for BackgroundTasks compatibility
|
||||
# 使用参数列表形式避免 shell=True 的命令注入风险
|
||||
cmd = [
|
||||
'ffprobe', '-v', 'error',
|
||||
'-show_entries', 'format=duration',
|
||||
'-of', 'default=noprint_wrappers=1:nokey=1',
|
||||
file_path
|
||||
]
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
return float(result.stdout.strip())
|
||||
except Exception:
|
||||
return 0.0
|
||||
|
||||
def mix_audio(
|
||||
self,
|
||||
voice_path: str,
|
||||
bgm_path: str,
|
||||
output_path: str,
|
||||
bgm_volume: float = 0.2
|
||||
) -> str:
|
||||
"""混合人声与背景音乐"""
|
||||
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
volume = max(0.0, min(float(bgm_volume), 1.0))
|
||||
filter_complex = (
|
||||
f"[0:a]volume=1.0[a0];"
|
||||
f"[1:a]volume={volume}[a1];"
|
||||
f"[a0][a1]amix=inputs=2:duration=first:dropout_transition=2:normalize=0[aout]"
|
||||
)
|
||||
|
||||
cmd = [
|
||||
"ffmpeg", "-y",
|
||||
"-i", voice_path,
|
||||
"-stream_loop", "-1", "-i", bgm_path,
|
||||
"-filter_complex", filter_complex,
|
||||
"-map", "[aout]",
|
||||
"-c:a", "pcm_s16le",
|
||||
"-shortest",
|
||||
output_path,
|
||||
]
|
||||
|
||||
if self._run_ffmpeg(cmd):
|
||||
return output_path
|
||||
raise RuntimeError("FFmpeg audio mix failed")
|
||||
|
||||
async def compose(
|
||||
self,
|
||||
video_path: str,
|
||||
audio_path: str,
|
||||
output_path: str,
|
||||
subtitle_path: Optional[str] = None
|
||||
) -> str:
|
||||
"""合成视频"""
|
||||
# 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)
|
||||
|
||||
# Audio loop if needed
|
||||
loop_count = 1
|
||||
if audio_duration > video_duration and video_duration > 0:
|
||||
loop_count = int(audio_duration / video_duration) + 1
|
||||
|
||||
cmd = ["ffmpeg", "-y"]
|
||||
|
||||
# Input video (stream_loop must be before -i)
|
||||
if loop_count > 1:
|
||||
cmd.extend(["-stream_loop", str(loop_count)])
|
||||
cmd.extend(["-i", video_path])
|
||||
|
||||
# Input audio
|
||||
cmd.extend(["-i", audio_path])
|
||||
|
||||
# Filter complex
|
||||
filter_complex = []
|
||||
|
||||
# Subtitles (skip for now to mimic previous state or implement basic)
|
||||
# Previous state: subtitles disabled due to font issues
|
||||
# if subtitle_path: ...
|
||||
|
||||
# Audio map with high quality encoding
|
||||
cmd.extend([
|
||||
"-c:v", "libx264",
|
||||
"-preset", "slow", # 慢速预设,更好的压缩效率
|
||||
"-crf", "18", # 高质量(与 LatentSync 一致)
|
||||
"-c:a", "aac",
|
||||
"-b:a", "192k", # 音频比特率
|
||||
"-shortest"
|
||||
])
|
||||
# Use audio from input 1
|
||||
cmd.extend(["-map", "0:v", "-map", "1:a"])
|
||||
|
||||
cmd.append(output_path)
|
||||
|
||||
if self._run_ffmpeg(cmd):
|
||||
return output_path
|
||||
else:
|
||||
raise RuntimeError("FFmpeg composition failed")
|
||||
|
||||
|
||||
def _run_ffmpeg(self, cmd: list) -> bool:
|
||||
cmd_str = ' '.join(shlex.quote(str(c)) for c in cmd)
|
||||
logger.debug(f"FFmpeg CMD: {cmd_str}")
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
shell=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding='utf-8',
|
||||
timeout=600,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
logger.error(f"FFmpeg Error: {result.stderr}")
|
||||
return False
|
||||
return True
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.error("FFmpeg timed out after 600s")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"FFmpeg Exception: {e}")
|
||||
return False
|
||||
|
||||
def _get_duration(self, file_path: str) -> float:
|
||||
# Synchronous call for BackgroundTasks compatibility
|
||||
# 使用参数列表形式避免 shell=True 的命令注入风险
|
||||
cmd = [
|
||||
'ffprobe', '-v', 'error',
|
||||
'-show_entries', 'format=duration',
|
||||
'-of', 'default=noprint_wrappers=1:nokey=1',
|
||||
file_path
|
||||
]
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
return float(result.stdout.strip())
|
||||
except Exception:
|
||||
return 0.0
|
||||
|
||||
def mix_audio(
|
||||
self,
|
||||
voice_path: str,
|
||||
bgm_path: str,
|
||||
output_path: str,
|
||||
bgm_volume: float = 0.2
|
||||
) -> str:
|
||||
"""混合人声与背景音乐"""
|
||||
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
volume = max(0.0, min(float(bgm_volume), 1.0))
|
||||
filter_complex = (
|
||||
f"[0:a]volume=1.0[a0];"
|
||||
f"[1:a]volume={volume}[a1];"
|
||||
f"[a0][a1]amix=inputs=2:duration=first:dropout_transition=2:normalize=0[aout]"
|
||||
)
|
||||
|
||||
cmd = [
|
||||
"ffmpeg", "-y",
|
||||
"-i", voice_path,
|
||||
"-stream_loop", "-1", "-i", bgm_path,
|
||||
"-filter_complex", filter_complex,
|
||||
"-map", "[aout]",
|
||||
"-c:a", "pcm_s16le",
|
||||
"-shortest",
|
||||
output_path,
|
||||
]
|
||||
|
||||
if self._run_ffmpeg(cmd):
|
||||
return output_path
|
||||
raise RuntimeError("FFmpeg audio mix failed")
|
||||
|
||||
async def compose(
|
||||
self,
|
||||
video_path: str,
|
||||
audio_path: str,
|
||||
output_path: str,
|
||||
subtitle_path: Optional[str] = None
|
||||
) -> str:
|
||||
"""合成视频"""
|
||||
# Ensure output dir
|
||||
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
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
|
||||
if audio_duration > video_duration and video_duration > 0:
|
||||
loop_count = int(audio_duration / video_duration) + 1
|
||||
|
||||
cmd = ["ffmpeg", "-y"]
|
||||
|
||||
# Input video (stream_loop must be before -i)
|
||||
if loop_count > 1:
|
||||
cmd.extend(["-stream_loop", str(loop_count)])
|
||||
cmd.extend(["-i", video_path])
|
||||
|
||||
# Input audio
|
||||
cmd.extend(["-i", audio_path])
|
||||
|
||||
# Filter complex
|
||||
filter_complex = []
|
||||
|
||||
# Subtitles (skip for now to mimic previous state or implement basic)
|
||||
# Previous state: subtitles disabled due to font issues
|
||||
# if subtitle_path: ...
|
||||
|
||||
# 不需要循环时用流复制(几乎瞬间完成),需要循环时才重编码
|
||||
if loop_count > 1:
|
||||
cmd.extend([
|
||||
"-c:v", "libx264", "-preset", "fast", "-crf", "18",
|
||||
])
|
||||
else:
|
||||
cmd.extend(["-c:v", "copy"])
|
||||
|
||||
cmd.extend([
|
||||
"-c:a", "aac",
|
||||
"-b:a", "192k",
|
||||
"-shortest",
|
||||
"-map", "0:v", "-map", "1:a",
|
||||
])
|
||||
|
||||
cmd.append(output_path)
|
||||
|
||||
ok = await loop.run_in_executor(None, self._run_ffmpeg, cmd)
|
||||
if ok:
|
||||
return output_path
|
||||
else:
|
||||
raise RuntimeError("FFmpeg composition failed")
|
||||
|
||||
def concat_videos(self, video_paths: list, output_path: str, target_fps: int = 25) -> str:
|
||||
"""使用 FFmpeg concat demuxer 拼接多个视频片段"""
|
||||
if not video_paths:
|
||||
raise ValueError("No video segments to concat")
|
||||
|
||||
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 生成 concat list 文件
|
||||
list_path = Path(output_path).parent / f"{Path(output_path).stem}_concat.txt"
|
||||
with open(list_path, "w", encoding="utf-8") as f:
|
||||
for vp in video_paths:
|
||||
f.write(f"file '{vp}'\n")
|
||||
|
||||
if not video_paths:
|
||||
raise ValueError("No video segments to concat")
|
||||
|
||||
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 生成 concat list 文件
|
||||
list_path = Path(output_path).parent / f"{Path(output_path).stem}_concat.txt"
|
||||
with open(list_path, "w", encoding="utf-8") as f:
|
||||
for vp in video_paths:
|
||||
f.write(f"file '{vp}'\n")
|
||||
|
||||
cmd = [
|
||||
"ffmpeg", "-y",
|
||||
"-f", "concat",
|
||||
@@ -260,48 +270,43 @@ class VideoService:
|
||||
"-fflags", "+genpts",
|
||||
"-i", str(list_path),
|
||||
"-an",
|
||||
"-vsync", "cfr",
|
||||
"-r", str(target_fps),
|
||||
"-c:v", "libx264",
|
||||
"-preset", "fast",
|
||||
"-crf", "18",
|
||||
"-pix_fmt", "yuv420p",
|
||||
"-c:v", "copy",
|
||||
"-movflags", "+faststart",
|
||||
output_path,
|
||||
]
|
||||
|
||||
try:
|
||||
if self._run_ffmpeg(cmd):
|
||||
return output_path
|
||||
else:
|
||||
raise RuntimeError("FFmpeg concat failed")
|
||||
finally:
|
||||
try:
|
||||
list_path.unlink(missing_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def split_audio(self, audio_path: str, start: float, end: float, output_path: str) -> str:
|
||||
"""用 FFmpeg 按时间范围切分音频"""
|
||||
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
duration = end - start
|
||||
if duration <= 0:
|
||||
raise ValueError(f"Invalid audio split range: start={start}, end={end}, duration={duration}")
|
||||
|
||||
cmd = [
|
||||
"ffmpeg", "-y",
|
||||
"-ss", str(start),
|
||||
"-t", str(duration),
|
||||
"-i", audio_path,
|
||||
"-c", "copy",
|
||||
output_path,
|
||||
]
|
||||
|
||||
if self._run_ffmpeg(cmd):
|
||||
return output_path
|
||||
raise RuntimeError(f"FFmpeg audio split failed: {start}-{end}")
|
||||
|
||||
|
||||
try:
|
||||
if self._run_ffmpeg(cmd):
|
||||
return output_path
|
||||
else:
|
||||
raise RuntimeError("FFmpeg concat failed")
|
||||
finally:
|
||||
try:
|
||||
list_path.unlink(missing_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def split_audio(self, audio_path: str, start: float, end: float, output_path: str) -> str:
|
||||
"""用 FFmpeg 按时间范围切分音频"""
|
||||
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
duration = end - start
|
||||
if duration <= 0:
|
||||
raise ValueError(f"Invalid audio split range: start={start}, end={end}, duration={duration}")
|
||||
|
||||
cmd = [
|
||||
"ffmpeg", "-y",
|
||||
"-ss", str(start),
|
||||
"-t", str(duration),
|
||||
"-i", audio_path,
|
||||
"-c", "copy",
|
||||
output_path,
|
||||
]
|
||||
|
||||
if self._run_ffmpeg(cmd):
|
||||
return output_path
|
||||
raise RuntimeError(f"FFmpeg audio split failed: {start}-{end}")
|
||||
|
||||
def get_resolution(self, file_path: str) -> tuple[int, int]:
|
||||
"""获取视频有效显示分辨率(考虑旋转元数据)。"""
|
||||
info = self.get_video_metadata(file_path)
|
||||
@@ -309,7 +314,7 @@ class VideoService:
|
||||
int(info.get("effective_width") or 0),
|
||||
int(info.get("effective_height") or 0),
|
||||
)
|
||||
|
||||
|
||||
def prepare_segment(self, video_path: str, target_duration: float, output_path: str,
|
||||
target_resolution: Optional[tuple] = None, source_start: float = 0.0,
|
||||
source_end: Optional[float] = None, target_fps: Optional[int] = None) -> str:
|
||||
@@ -339,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
|
||||
|
||||
# 当需要循环且存在截取范围时,先裁剪出片段,再循环裁剪后的文件
|
||||
@@ -356,50 +362,50 @@ class VideoService:
|
||||
"-c:v", "libx264", "-preset", "fast", "-crf", "18",
|
||||
trim_temp,
|
||||
]
|
||||
if not self._run_ffmpeg(trim_cmd):
|
||||
raise RuntimeError(f"FFmpeg trim for loop failed: {video_path}")
|
||||
actual_input = trim_temp
|
||||
source_start = 0.0 # 已裁剪,不需要再 seek
|
||||
# 重新计算循环次数(基于裁剪后文件)
|
||||
available = self._get_duration(trim_temp) or available
|
||||
|
||||
loop_count = int(target_duration / available) + 1 if needs_loop else 0
|
||||
|
||||
cmd = ["ffmpeg", "-y"]
|
||||
if needs_loop:
|
||||
cmd.extend(["-stream_loop", str(loop_count)])
|
||||
if not self._run_ffmpeg(trim_cmd):
|
||||
raise RuntimeError(f"FFmpeg trim for loop failed: {video_path}")
|
||||
actual_input = trim_temp
|
||||
source_start = 0.0 # 已裁剪,不需要再 seek
|
||||
# 重新计算循环次数(基于裁剪后文件)
|
||||
available = self._get_duration(trim_temp) or available
|
||||
|
||||
loop_count = int(target_duration / available) + 1 if needs_loop else 0
|
||||
|
||||
cmd = ["ffmpeg", "-y"]
|
||||
if needs_loop:
|
||||
cmd.extend(["-stream_loop", str(loop_count)])
|
||||
if source_start > 0:
|
||||
cmd.extend(["-ss", str(source_start)])
|
||||
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", "18"])
|
||||
else:
|
||||
cmd.extend(["-c:v", "copy"])
|
||||
|
||||
cmd.append(output_path)
|
||||
|
||||
try:
|
||||
if self._run_ffmpeg(cmd):
|
||||
return output_path
|
||||
raise RuntimeError(f"FFmpeg prepare_segment failed: {video_path}")
|
||||
finally:
|
||||
# 清理裁剪临时文件
|
||||
if trim_temp:
|
||||
try:
|
||||
Path(trim_temp).unlink(missing_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
cmd.append(output_path)
|
||||
|
||||
try:
|
||||
if self._run_ffmpeg(cmd):
|
||||
return output_path
|
||||
raise RuntimeError(f"FFmpeg prepare_segment failed: {video_path}")
|
||||
finally:
|
||||
# 清理裁剪临时文件
|
||||
if trim_temp:
|
||||
try:
|
||||
Path(trim_temp).unlink(missing_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -32,6 +32,7 @@ class VoiceCloneService:
|
||||
ref_text: str,
|
||||
language: str,
|
||||
speed: float = 1.0,
|
||||
instruct_text: str = "",
|
||||
max_retries: int = 4,
|
||||
) -> bytes:
|
||||
timeout = httpx.Timeout(240.0)
|
||||
@@ -39,15 +40,18 @@ class VoiceCloneService:
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
data = {
|
||||
"text": text,
|
||||
"ref_text": ref_text,
|
||||
"language": language,
|
||||
"speed": str(speed),
|
||||
}
|
||||
if instruct_text:
|
||||
data["instruct_text"] = instruct_text
|
||||
response = await client.post(
|
||||
f"{self.base_url}/generate",
|
||||
files={"ref_audio": ("ref.wav", ref_audio_data, "audio/wav")},
|
||||
data={
|
||||
"text": text,
|
||||
"ref_text": ref_text,
|
||||
"language": language,
|
||||
"speed": str(speed),
|
||||
},
|
||||
data=data,
|
||||
)
|
||||
|
||||
retryable = False
|
||||
@@ -99,6 +103,7 @@ class VoiceCloneService:
|
||||
output_path: str,
|
||||
language: str = "Chinese",
|
||||
speed: float = 1.0,
|
||||
instruct_text: str = "",
|
||||
) -> str:
|
||||
"""
|
||||
使用声音克隆生成语音
|
||||
@@ -132,6 +137,7 @@ class VoiceCloneService:
|
||||
ref_text=ref_text,
|
||||
language=language,
|
||||
speed=speed,
|
||||
instruct_text=instruct_text,
|
||||
)
|
||||
with open(output_path, "wb") as f:
|
||||
f.write(audio_bytes)
|
||||
|
||||
@@ -151,6 +151,46 @@ def split_segment_to_lines(words: List[dict], max_chars: int = MAX_CHARS_PER_LIN
|
||||
return segments
|
||||
|
||||
|
||||
def smooth_word_timestamps(words: List[dict]) -> List[dict]:
|
||||
"""
|
||||
时间戳后处理平滑:
|
||||
1. 保证时间戳严格单调递增
|
||||
2. 消除 Whisper 输出中的微小抖动(字的 end > 下一字的 start)
|
||||
3. 填补字间间隙,避免字幕高亮"跳空"
|
||||
"""
|
||||
if len(words) <= 1:
|
||||
return words
|
||||
|
||||
result = [words[0].copy()]
|
||||
for i in range(1, len(words)):
|
||||
w = words[i].copy()
|
||||
prev = result[-1]
|
||||
|
||||
# 保证 start 不早于前一字的 start(单调递增)
|
||||
if w["start"] < prev["start"]:
|
||||
w["start"] = prev["start"]
|
||||
|
||||
# 保证 start 不早于前一字的 end
|
||||
if w["start"] < prev["end"]:
|
||||
# 两字重叠,取中点分割
|
||||
mid = (prev["end"] + w["start"]) / 2
|
||||
prev["end"] = round(mid, 3)
|
||||
w["start"] = round(mid, 3)
|
||||
|
||||
# 填补字间间隙(间隙 < 50ms 时直接连接,避免高亮跳空)
|
||||
gap = w["start"] - prev["end"]
|
||||
if 0 < gap < 0.05:
|
||||
prev["end"] = w["start"]
|
||||
|
||||
# 保证 end >= start
|
||||
if w["end"] < w["start"]:
|
||||
w["end"] = w["start"] + 0.05
|
||||
|
||||
result.append(w)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
class WhisperService:
|
||||
"""字幕对齐服务(基于 faster-whisper)"""
|
||||
|
||||
@@ -219,6 +259,8 @@ class WhisperService:
|
||||
language=language,
|
||||
word_timestamps=True, # 启用字级别时间戳
|
||||
vad_filter=True, # 启用 VAD 过滤静音
|
||||
beam_size=8, # 增大搜索宽度,提升时间戳精度
|
||||
# condition_on_previous_text 保持默认 True,避免时间戳系统性超前
|
||||
)
|
||||
|
||||
logger.info(f"Detected language: {info.language} (prob: {info.language_probability:.2f})")
|
||||
@@ -244,22 +286,89 @@ class WhisperService:
|
||||
all_words.extend(chars)
|
||||
|
||||
if all_words:
|
||||
all_words = smooth_word_timestamps(all_words)
|
||||
line_segments = split_segment_to_lines(all_words, max_chars)
|
||||
all_segments.extend(line_segments)
|
||||
|
||||
# 如果提供了 original_text,用原文替换 Whisper 转录文字
|
||||
# 如果提供了 original_text,用原文替换 Whisper 转录文字,保留语音节奏
|
||||
if original_text and original_text.strip() and whisper_first_start is not None:
|
||||
logger.info(f"Using original_text for subtitles (len={len(original_text)}), "
|
||||
f"Whisper time range: {whisper_first_start:.2f}-{whisper_last_end:.2f}s")
|
||||
# 用 split_word_to_chars 拆分原文
|
||||
# 收集 Whisper 逐字时间戳(保留真实语音节奏)
|
||||
whisper_chars = []
|
||||
for seg in all_segments:
|
||||
whisper_chars.extend(seg.get("words", []))
|
||||
|
||||
# 用原文字符 + Whisper 节奏生成新的时间戳
|
||||
orig_chars = split_word_to_chars(
|
||||
original_text.strip(),
|
||||
whisper_first_start,
|
||||
whisper_last_end
|
||||
)
|
||||
if orig_chars:
|
||||
all_segments = split_segment_to_lines(orig_chars, max_chars)
|
||||
logger.info(f"Rebuilt {len(all_segments)} subtitle segments from original text")
|
||||
|
||||
if orig_chars and len(whisper_chars) >= 2:
|
||||
# 将原文字符按比例映射到 Whisper 的时间节奏上
|
||||
n_w = len(whisper_chars)
|
||||
n_o = len(orig_chars)
|
||||
w_starts = [c["start"] for c in whisper_chars]
|
||||
w_final_end = whisper_chars[-1]["end"]
|
||||
|
||||
# 字数比例异常检测
|
||||
ratio = n_o / n_w
|
||||
if ratio > 1.5 or ratio < 0.67:
|
||||
logger.warning(
|
||||
f"original_text 与 Whisper 字数比例异常: {n_o}/{n_w} = {ratio:.2f}, "
|
||||
f"字幕时间戳精度可能下降"
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Using original_text for subtitles (len={len(original_text)}), "
|
||||
f"rhythm-mapping {n_o} orig chars onto {n_w} Whisper chars, "
|
||||
f"time range: {whisper_first_start:.2f}-{whisper_last_end:.2f}s"
|
||||
)
|
||||
|
||||
remapped = []
|
||||
for i, oc in enumerate(orig_chars):
|
||||
# 原文第 i 个字符对应 Whisper 时间线的位置
|
||||
pos = (i / n_o) * n_w
|
||||
idx = min(int(pos), n_w - 1)
|
||||
frac = pos - idx
|
||||
t_start = (
|
||||
w_starts[idx] + frac * (w_starts[idx + 1] - w_starts[idx])
|
||||
if idx < n_w - 1
|
||||
else w_starts[idx] + frac * (w_final_end - w_starts[idx])
|
||||
)
|
||||
|
||||
# 结束时间 = 下一个字符的开始时间
|
||||
pos_next = ((i + 1) / n_o) * n_w
|
||||
idx_n = min(int(pos_next), n_w - 1)
|
||||
frac_n = pos_next - idx_n
|
||||
t_end = (
|
||||
w_starts[idx_n] + frac_n * (w_starts[idx_n + 1] - w_starts[idx_n])
|
||||
if idx_n < n_w - 1
|
||||
else w_starts[idx_n] + frac_n * (w_final_end - w_starts[idx_n])
|
||||
)
|
||||
|
||||
remapped.append({
|
||||
"word": oc["word"],
|
||||
"start": round(t_start, 3),
|
||||
"end": round(t_end, 3),
|
||||
})
|
||||
|
||||
# 限制单字时长范围,防止比例异常时极端漂移
|
||||
MIN_CHAR_DURATION = 0.04 # 40ms(一帧@25fps)
|
||||
MAX_CHAR_DURATION = 0.8 # 800ms
|
||||
for r in remapped:
|
||||
dur = r["end"] - r["start"]
|
||||
if dur < MIN_CHAR_DURATION:
|
||||
r["end"] = round(r["start"] + MIN_CHAR_DURATION, 3)
|
||||
elif dur > MAX_CHAR_DURATION:
|
||||
r["end"] = round(r["start"] + MAX_CHAR_DURATION, 3)
|
||||
|
||||
all_segments = split_segment_to_lines(smooth_word_timestamps(remapped), max_chars)
|
||||
logger.info(f"Rebuilt {len(all_segments)} subtitle segments (rhythm-mapped)")
|
||||
elif orig_chars:
|
||||
# Whisper 字符不足,退回线性插值
|
||||
all_segments = split_segment_to_lines(smooth_word_timestamps(orig_chars), max_chars)
|
||||
logger.info(f"Rebuilt {len(all_segments)} subtitle segments (linear fallback)")
|
||||
|
||||
logger.info(f"Generated {len(all_segments)} subtitle segments")
|
||||
return {"segments": all_segments}
|
||||
|
||||
@@ -54,5 +54,61 @@
|
||||
"letter_spacing": 1,
|
||||
"bottom_margin": 72,
|
||||
"is_default": false
|
||||
},
|
||||
{
|
||||
"id": "subtitle_pink",
|
||||
"label": "少女粉",
|
||||
"font_file": "DingTalk JinBuTi.ttf",
|
||||
"font_family": "DingTalkJinBuTi",
|
||||
"font_size": 56,
|
||||
"highlight_color": "#FF69B4",
|
||||
"normal_color": "#FFFFFF",
|
||||
"stroke_color": "#1A0010",
|
||||
"stroke_size": 3,
|
||||
"letter_spacing": 2,
|
||||
"bottom_margin": 80,
|
||||
"is_default": false
|
||||
},
|
||||
{
|
||||
"id": "subtitle_lime",
|
||||
"label": "清新绿",
|
||||
"font_file": "DingTalk Sans.ttf",
|
||||
"font_family": "DingTalkSans",
|
||||
"font_size": 50,
|
||||
"highlight_color": "#76FF03",
|
||||
"normal_color": "#FFFFFF",
|
||||
"stroke_color": "#001A00",
|
||||
"stroke_size": 3,
|
||||
"letter_spacing": 1,
|
||||
"bottom_margin": 78,
|
||||
"is_default": false
|
||||
},
|
||||
{
|
||||
"id": "subtitle_gold",
|
||||
"label": "金色隶书",
|
||||
"font_file": "阿里妈妈刀隶体.ttf",
|
||||
"font_family": "AliMamaDaoLiTi",
|
||||
"font_size": 56,
|
||||
"highlight_color": "#FDE68A",
|
||||
"normal_color": "#E8D5B0",
|
||||
"stroke_color": "#2B1B00",
|
||||
"stroke_size": 3,
|
||||
"letter_spacing": 3,
|
||||
"bottom_margin": 80,
|
||||
"is_default": false
|
||||
},
|
||||
{
|
||||
"id": "subtitle_kai",
|
||||
"label": "楷体红字",
|
||||
"font_file": "simkai.ttf",
|
||||
"font_family": "SimKai",
|
||||
"font_size": 54,
|
||||
"highlight_color": "#FF4444",
|
||||
"normal_color": "#FFFFFF",
|
||||
"stroke_color": "#000000",
|
||||
"stroke_size": 3,
|
||||
"letter_spacing": 2,
|
||||
"bottom_margin": 80,
|
||||
"is_default": false
|
||||
}
|
||||
]
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"font_size": 90,
|
||||
"color": "#FFFFFF",
|
||||
"stroke_color": "#000000",
|
||||
"stroke_size": 8,
|
||||
"stroke_size": 5,
|
||||
"letter_spacing": 5,
|
||||
"top_margin": 62,
|
||||
"font_weight": 900,
|
||||
@@ -21,7 +21,7 @@
|
||||
"font_size": 72,
|
||||
"color": "#FFFFFF",
|
||||
"stroke_color": "#000000",
|
||||
"stroke_size": 8,
|
||||
"stroke_size": 5,
|
||||
"letter_spacing": 4,
|
||||
"top_margin": 60,
|
||||
"font_weight": 900,
|
||||
@@ -35,7 +35,7 @@
|
||||
"font_size": 70,
|
||||
"color": "#FDE68A",
|
||||
"stroke_color": "#2B1B00",
|
||||
"stroke_size": 8,
|
||||
"stroke_size": 5,
|
||||
"letter_spacing": 3,
|
||||
"top_margin": 58,
|
||||
"font_weight": 800,
|
||||
@@ -49,10 +49,122 @@
|
||||
"font_size": 72,
|
||||
"color": "#FFFFFF",
|
||||
"stroke_color": "#1F0A00",
|
||||
"stroke_size": 8,
|
||||
"stroke_size": 5,
|
||||
"letter_spacing": 4,
|
||||
"top_margin": 60,
|
||||
"font_weight": 900,
|
||||
"is_default": false
|
||||
},
|
||||
{
|
||||
"id": "title_pangmen",
|
||||
"label": "庞门正道",
|
||||
"font_file": "title/庞门正道标题体3.0.ttf",
|
||||
"font_family": "PangMenZhengDao",
|
||||
"font_size": 80,
|
||||
"color": "#FFFFFF",
|
||||
"stroke_color": "#000000",
|
||||
"stroke_size": 5,
|
||||
"letter_spacing": 5,
|
||||
"top_margin": 60,
|
||||
"font_weight": 900,
|
||||
"is_default": false
|
||||
},
|
||||
{
|
||||
"id": "title_round",
|
||||
"label": "优设标题圆",
|
||||
"font_file": "title/优设标题圆.otf",
|
||||
"font_family": "YouSheBiaoTiYuan",
|
||||
"font_size": 78,
|
||||
"color": "#FFFFFF",
|
||||
"stroke_color": "#4A1A6B",
|
||||
"stroke_size": 5,
|
||||
"letter_spacing": 4,
|
||||
"top_margin": 60,
|
||||
"font_weight": 900,
|
||||
"is_default": false
|
||||
},
|
||||
{
|
||||
"id": "title_alibaba",
|
||||
"label": "阿里数黑体",
|
||||
"font_file": "title/阿里巴巴数黑体.ttf",
|
||||
"font_family": "AlibabaShuHeiTi",
|
||||
"font_size": 72,
|
||||
"color": "#FFFFFF",
|
||||
"stroke_color": "#000000",
|
||||
"stroke_size": 4,
|
||||
"letter_spacing": 3,
|
||||
"top_margin": 60,
|
||||
"font_weight": 900,
|
||||
"is_default": false
|
||||
},
|
||||
{
|
||||
"id": "title_chaohei",
|
||||
"label": "文道潮黑",
|
||||
"font_file": "title/文道潮黑.ttf",
|
||||
"font_family": "WenDaoChaoHei",
|
||||
"font_size": 76,
|
||||
"color": "#00E5FF",
|
||||
"stroke_color": "#001A33",
|
||||
"stroke_size": 5,
|
||||
"letter_spacing": 4,
|
||||
"top_margin": 60,
|
||||
"font_weight": 900,
|
||||
"is_default": false
|
||||
},
|
||||
{
|
||||
"id": "title_wujie",
|
||||
"label": "无界黑",
|
||||
"font_file": "title/标小智无界黑.otf",
|
||||
"font_family": "BiaoXiaoZhiWuJieHei",
|
||||
"font_size": 74,
|
||||
"color": "#FFFFFF",
|
||||
"stroke_color": "#1A1A1A",
|
||||
"stroke_size": 4,
|
||||
"letter_spacing": 3,
|
||||
"top_margin": 60,
|
||||
"font_weight": 900,
|
||||
"is_default": false
|
||||
},
|
||||
{
|
||||
"id": "title_houdi",
|
||||
"label": "厚底黑",
|
||||
"font_file": "title/Aa厚底黑.ttf",
|
||||
"font_family": "AaHouDiHei",
|
||||
"font_size": 76,
|
||||
"color": "#FF6B6B",
|
||||
"stroke_color": "#1A0000",
|
||||
"stroke_size": 5,
|
||||
"letter_spacing": 4,
|
||||
"top_margin": 60,
|
||||
"font_weight": 900,
|
||||
"is_default": false
|
||||
},
|
||||
{
|
||||
"id": "title_banyuan",
|
||||
"label": "寒蝉半圆体",
|
||||
"font_file": "title/寒蝉半圆体.otf",
|
||||
"font_family": "HanChanBanYuan",
|
||||
"font_size": 78,
|
||||
"color": "#FFFFFF",
|
||||
"stroke_color": "#000000",
|
||||
"stroke_size": 5,
|
||||
"letter_spacing": 4,
|
||||
"top_margin": 60,
|
||||
"font_weight": 900,
|
||||
"is_default": false
|
||||
},
|
||||
{
|
||||
"id": "title_jixiang",
|
||||
"label": "欣意吉祥宋",
|
||||
"font_file": "title/字体圈欣意吉祥宋.ttf",
|
||||
"font_family": "XinYiJiXiangSong",
|
||||
"font_size": 70,
|
||||
"color": "#FDE68A",
|
||||
"stroke_color": "#2B1B00",
|
||||
"stroke_size": 5,
|
||||
"letter_spacing": 3,
|
||||
"top_margin": 58,
|
||||
"font_weight": 800,
|
||||
"is_default": false
|
||||
}
|
||||
]
|
||||
|
||||
@@ -71,3 +71,18 @@ CREATE TRIGGER users_updated_at
|
||||
BEFORE UPDATE ON users
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_updated_at();
|
||||
|
||||
-- 8. 订单表(支付宝付费)
|
||||
CREATE TABLE IF NOT EXISTS orders (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
|
||||
out_trade_no TEXT UNIQUE NOT NULL,
|
||||
amount DECIMAL(10, 2) NOT NULL DEFAULT 999.00,
|
||||
status TEXT DEFAULT 'pending' CHECK (status IN ('pending', 'paid', 'failed')),
|
||||
trade_no TEXT,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
paid_at TIMESTAMP WITH TIME ZONE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_orders_user_id ON orders(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_orders_out_trade_no ON orders(out_trade_no);
|
||||
|
||||
31
backend/package-lock.json
generated
Normal file
31
backend/package-lock.json
generated
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "backend",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"dependencies": {
|
||||
"qrcode.react": "^4.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode.react": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz",
|
||||
"integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react": {
|
||||
"version": "19.2.4",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
|
||||
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
5
backend/package.json
Normal file
5
backend/package.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"dependencies": {
|
||||
"qrcode.react": "^4.2.0"
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,9 @@ python-jose[cryptography]>=3.3.0
|
||||
passlib[bcrypt]>=1.7.4
|
||||
bcrypt==4.0.1
|
||||
|
||||
# 支付宝支付
|
||||
python-alipay-sdk>=3.6.0
|
||||
|
||||
# 字幕对齐
|
||||
faster-whisper>=1.0.0
|
||||
|
||||
|
||||
10
frontend/package-lock.json
generated
10
frontend/package-lock.json
generated
@@ -15,6 +15,7 @@
|
||||
"axios": "^1.13.4",
|
||||
"lucide-react": "^0.563.0",
|
||||
"next": "16.1.1",
|
||||
"qrcode.react": "^4.2.0",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"sonner": "^2.0.7",
|
||||
@@ -5618,6 +5619,15 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode.react": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz",
|
||||
"integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/queue-microtask": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
"axios": "^1.13.4",
|
||||
"lucide-react": "^0.563.0",
|
||||
"next": "16.1.1",
|
||||
"qrcode.react": "^4.2.0",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"sonner": "^2.0.7",
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 3.6 KiB |
BIN
frontend/src/app/icon.png
Normal file
BIN
frontend/src/app/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.6 KiB |
@@ -3,6 +3,7 @@ import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { AuthProvider } from "@/shared/contexts/AuthContext";
|
||||
import { TaskProvider } from "@/shared/contexts/TaskContext";
|
||||
import { CleanupProvider } from "@/shared/contexts/CleanupContext";
|
||||
|
||||
import { Toaster } from "sonner";
|
||||
|
||||
@@ -40,7 +41,9 @@ export default function RootLayout({
|
||||
>
|
||||
<AuthProvider>
|
||||
<TaskProvider>
|
||||
{children}
|
||||
<CleanupProvider>
|
||||
{children}
|
||||
</CleanupProvider>
|
||||
</TaskProvider>
|
||||
</AuthProvider>
|
||||
<Toaster
|
||||
|
||||
@@ -3,9 +3,11 @@
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { login } from "@/shared/lib/auth";
|
||||
import { useAuth } from "@/shared/contexts/AuthContext";
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
const { setUser } = useAuth();
|
||||
const [phone, setPhone] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
@@ -25,7 +27,11 @@ export default function LoginPage() {
|
||||
|
||||
try {
|
||||
const result = await login(phone, password);
|
||||
if (result.success) {
|
||||
if (result.paymentToken) {
|
||||
sessionStorage.setItem('payment_token', result.paymentToken);
|
||||
router.push('/pay');
|
||||
} else if (result.success) {
|
||||
if (result.user) setUser(result.user);
|
||||
router.push('/');
|
||||
} else {
|
||||
setError(result.message || '登录失败');
|
||||
|
||||
160
frontend/src/app/pay/page.tsx
Normal file
160
frontend/src/app/pay/page.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
'use client';
|
||||
|
||||
import { Suspense, useState, useEffect, useRef } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import api from '@/shared/api/axios';
|
||||
|
||||
type PageStatus = 'loading' | 'redirecting' | 'checking' | 'success' | 'error';
|
||||
|
||||
function PayContent() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [status, setStatus] = useState<PageStatus>('loading');
|
||||
const [errorMsg, setErrorMsg] = useState('');
|
||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const outTradeNo = searchParams.get('out_trade_no');
|
||||
if (outTradeNo) {
|
||||
setStatus('checking');
|
||||
startPolling(outTradeNo);
|
||||
return;
|
||||
}
|
||||
|
||||
const token = sessionStorage.getItem('payment_token');
|
||||
if (!token) {
|
||||
router.replace('/login');
|
||||
return;
|
||||
}
|
||||
createOrder(token);
|
||||
|
||||
return () => {
|
||||
if (pollRef.current) clearInterval(pollRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const createOrder = async (token: string) => {
|
||||
try {
|
||||
const { data } = await api.post('/api/payment/create-order', { payment_token: token });
|
||||
const { pay_url } = data.data;
|
||||
setStatus('redirecting');
|
||||
window.location.href = pay_url;
|
||||
} catch (err: any) {
|
||||
setStatus('error');
|
||||
setErrorMsg(err.response?.data?.message || '创建订单失败,请重新登录');
|
||||
}
|
||||
};
|
||||
|
||||
const startPolling = (tradeNo: string) => {
|
||||
checkStatus(tradeNo);
|
||||
pollRef.current = setInterval(() => checkStatus(tradeNo), 3000);
|
||||
};
|
||||
|
||||
const checkStatus = async (tradeNo: string) => {
|
||||
try {
|
||||
const { data } = await api.get(`/api/payment/status/${tradeNo}`);
|
||||
if (data.data.status === 'paid') {
|
||||
if (pollRef.current) clearInterval(pollRef.current);
|
||||
setStatus('success');
|
||||
sessionStorage.removeItem('payment_token');
|
||||
setTimeout(() => router.replace('/login'), 3000);
|
||||
}
|
||||
} catch {
|
||||
// ignore polling errors
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-md p-8 bg-white/10 backdrop-blur-lg rounded-2xl shadow-2xl border border-white/20">
|
||||
{(status === 'loading' || status === 'redirecting') && (
|
||||
<div className="text-center">
|
||||
<div className="mb-6">
|
||||
<svg className="animate-spin h-12 w-12 mx-auto text-purple-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-gray-300">
|
||||
{status === 'loading' ? '正在创建订单...' : '正在跳转到支付宝...'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === 'checking' && (
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl font-bold text-white mb-6">支付确认中</h1>
|
||||
<div className="flex items-center justify-center gap-2 text-purple-300 mb-4">
|
||||
<svg className="animate-spin h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
正在确认支付结果...
|
||||
</div>
|
||||
<p className="text-gray-400 text-sm">如果您已完成支付,请稍候</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === 'success' && (
|
||||
<div className="text-center">
|
||||
<div className="mb-6">
|
||||
<svg className="w-16 h-16 mx-auto text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-white mb-4">支付成功!</h2>
|
||||
<p className="text-gray-300 mb-2">会员已开通,即将跳转到登录页...</p>
|
||||
<p className="text-gray-500 text-sm">请重新登录即可使用</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === 'error' && (
|
||||
<div className="text-center">
|
||||
<div className="mb-6">
|
||||
<svg className="w-16 h-16 mx-auto text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-white mb-4">创建订单失败</h2>
|
||||
<p className="text-red-300 mb-6">{errorMsg}</p>
|
||||
<button
|
||||
onClick={() => router.replace('/login')}
|
||||
className="py-3 px-6 bg-gradient-to-r from-purple-600 to-pink-600 text-white font-semibold rounded-lg"
|
||||
>
|
||||
返回登录
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === 'checking' && (
|
||||
<div className="mt-6 text-center">
|
||||
<button
|
||||
onClick={() => {
|
||||
if (pollRef.current) clearInterval(pollRef.current);
|
||||
router.replace('/login');
|
||||
}}
|
||||
className="text-purple-300 hover:text-purple-200 text-sm"
|
||||
>
|
||||
返回登录
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PayPage() {
|
||||
return (
|
||||
<div className="min-h-dvh flex items-center justify-center">
|
||||
<Suspense fallback={
|
||||
<div className="w-full max-w-md p-8 bg-white/10 backdrop-blur-lg rounded-2xl shadow-2xl border border-white/20 text-center">
|
||||
<svg className="animate-spin h-12 w-12 mx-auto text-purple-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
}>
|
||||
<PayContent />
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -61,7 +61,7 @@ export default function RegisterPage() {
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-white mb-4">注册成功!</h2>
|
||||
<p className="text-gray-300 mb-6">
|
||||
您的账号已创建,请等待管理员审核激活后即可登录。
|
||||
注册成功!请返回登录页,登录后完成付费即可开通。
|
||||
</p>
|
||||
<a
|
||||
href="/login"
|
||||
|
||||
@@ -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
|
||||
@@ -106,6 +116,10 @@ export default function AccountSettingsDropdown() {
|
||||
{/* 下拉菜单 */}
|
||||
{isOpen && (
|
||||
<div className="absolute right-0 mt-2 bg-gray-800 border border-white/10 rounded-lg shadow-xl z-[160] overflow-hidden whitespace-nowrap">
|
||||
{/* 账户名称 */}
|
||||
<div className="px-3 py-2 border-b border-white/10 text-center">
|
||||
<div className="text-sm text-white font-medium">{user?.phone ? `${user.phone.slice(0, 3)}****${user.phone.slice(-4)}` : '未知账户'}</div>
|
||||
</div>
|
||||
{/* 有效期显示 */}
|
||||
<div className="px-3 py-2 border-b border-white/10 text-center">
|
||||
<div className="text-xs text-gray-400">账户有效期</div>
|
||||
@@ -133,80 +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('');
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -127,6 +127,7 @@ export const useGeneratedAudios = ({
|
||||
ref_text?: string;
|
||||
language: string;
|
||||
speed?: number;
|
||||
instruct_text?: string;
|
||||
}) => {
|
||||
setIsGeneratingAudio(true);
|
||||
setAudioTask({ status: "pending", progress: 0, message: "正在提交..." });
|
||||
|
||||
@@ -12,7 +12,7 @@ interface GeneratedVideo {
|
||||
}
|
||||
|
||||
interface UseGeneratedVideosOptions {
|
||||
|
||||
storageKey: string;
|
||||
selectedVideoId: string | null;
|
||||
setSelectedVideoId: React.Dispatch<React.SetStateAction<string | null>>;
|
||||
setGeneratedVideo: React.Dispatch<React.SetStateAction<string | null>>;
|
||||
@@ -20,7 +20,7 @@ interface UseGeneratedVideosOptions {
|
||||
}
|
||||
|
||||
export const useGeneratedVideos = ({
|
||||
|
||||
storageKey,
|
||||
selectedVideoId,
|
||||
setSelectedVideoId,
|
||||
setGeneratedVideo,
|
||||
@@ -45,6 +45,8 @@ export const useGeneratedVideos = ({
|
||||
if (preferVideoId === "__latest__") {
|
||||
setSelectedVideoId(videos[0].id);
|
||||
setGeneratedVideo(resolveMediaUrl(videos[0].path));
|
||||
// 写入跨页面共享标记,让另一个页面也能感知最新生成的视频
|
||||
localStorage.setItem(`vigent_${storageKey}_latestGeneratedVideoId`, videos[0].id);
|
||||
} else {
|
||||
const found = videos.find(v => v.id === preferVideoId);
|
||||
if (found) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import api from "@/shared/api/axios";
|
||||
import {
|
||||
buildTextShadow,
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
resolveBgmUrl,
|
||||
resolveMediaUrl,
|
||||
} from "@/shared/lib/media";
|
||||
import { clampTitle } from "@/shared/lib/title";
|
||||
import { clampTitle, clampSecondaryTitle, SECONDARY_TITLE_MAX_LENGTH } from "@/shared/lib/title";
|
||||
import { useTitleInput } from "@/shared/hooks/useTitleInput";
|
||||
import { useAuth } from "@/shared/contexts/AuthContext";
|
||||
import { useTask } from "@/shared/contexts/TaskContext";
|
||||
@@ -26,6 +26,7 @@ import { useRefAudios } from "@/features/home/model/useRefAudios";
|
||||
import { useTitleSubtitleStyles } from "@/features/home/model/useTitleSubtitleStyles";
|
||||
import { useTimelineEditor } from "@/features/home/model/useTimelineEditor";
|
||||
import { useSavedScripts } from "@/features/home/model/useSavedScripts";
|
||||
import { useVideoFrameCapture } from "@/features/home/model/useVideoFrameCapture";
|
||||
import { ApiResponse, unwrap } from "@/shared/api/types";
|
||||
|
||||
const VOICES: Record<string, { id: string; name: string }[]> = {
|
||||
@@ -123,6 +124,8 @@ interface RefAudio {
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
type LipsyncModelMode = "default" | "fast" | "advanced";
|
||||
|
||||
import type { Material } from "@/shared/types/material";
|
||||
|
||||
export const useHomeController = () => {
|
||||
@@ -154,9 +157,17 @@ 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);
|
||||
|
||||
// 副标题相关状态
|
||||
const [videoSecondaryTitle, setVideoSecondaryTitle] = useState<string>("");
|
||||
const [selectedSecondaryTitleStyleId, setSelectedSecondaryTitleStyleId] = useState<string>("");
|
||||
const [secondaryTitleFontSize, setSecondaryTitleFontSize] = useState<number>(48);
|
||||
const [secondaryTitleTopMargin, setSecondaryTitleTopMargin] = useState<number>(12);
|
||||
const [secondaryTitleSizeLocked, setSecondaryTitleSizeLocked] = useState<boolean>(false);
|
||||
|
||||
|
||||
// 背景音乐相关状态
|
||||
const [selectedBgmId, setSelectedBgmId] = useState<string>("");
|
||||
@@ -174,6 +185,9 @@ export const useHomeController = () => {
|
||||
// 语速控制
|
||||
const [speed, setSpeed] = useState<number>(1.0);
|
||||
|
||||
// 语气控制(仅声音克隆模式)
|
||||
const [emotion, setEmotion] = useState<string>("normal");
|
||||
|
||||
// ClipTrimmer 模态框状态
|
||||
const [clipTrimmerOpen, setClipTrimmerOpen] = useState(false);
|
||||
const [clipTrimmerSegmentId, setClipTrimmerSegmentId] = useState<string | null>(null);
|
||||
@@ -273,6 +287,9 @@ export const useHomeController = () => {
|
||||
// 文案提取模态框
|
||||
const [extractModalOpen, setExtractModalOpen] = useState(false);
|
||||
|
||||
// AI 改写模态框
|
||||
const [rewriteModalOpen, setRewriteModalOpen] = useState(false);
|
||||
|
||||
// 获取存储 key 的前缀(登录用户使用 userId,未登录使用 guest)
|
||||
const storageKey = userId || "guest";
|
||||
|
||||
@@ -354,7 +371,7 @@ export const useHomeController = () => {
|
||||
fetchGeneratedVideos,
|
||||
deleteVideo,
|
||||
} = useGeneratedVideos({
|
||||
|
||||
storageKey,
|
||||
selectedVideoId,
|
||||
setSelectedVideoId,
|
||||
setGeneratedVideo,
|
||||
@@ -388,6 +405,19 @@ export const useHomeController = () => {
|
||||
storageKey,
|
||||
});
|
||||
|
||||
// 时间轴第一段素材的视频 URL(用于帧截取预览)
|
||||
// 使用后端代理 URL(同源)避免 CORS canvas taint
|
||||
const firstTimelineMaterialUrl = useMemo(() => {
|
||||
const firstSeg = timelineSegments[0];
|
||||
const matId = firstSeg?.materialId ?? selectedMaterials[0];
|
||||
if (!matId) return null;
|
||||
const mat = materials.find((m) => m.id === matId);
|
||||
if (!mat) return null;
|
||||
return `/api/materials/stream/${mat.id}`;
|
||||
}, [materials, timelineSegments, selectedMaterials]);
|
||||
|
||||
const materialPosterUrl = useVideoFrameCapture(showStylePreview ? firstTimelineMaterialUrl : null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthLoading || !userId) return;
|
||||
let active = true;
|
||||
@@ -430,6 +460,8 @@ export const useHomeController = () => {
|
||||
setText,
|
||||
videoTitle,
|
||||
setVideoTitle,
|
||||
videoSecondaryTitle,
|
||||
setVideoSecondaryTitle,
|
||||
ttsMode,
|
||||
setTtsMode,
|
||||
voice,
|
||||
@@ -442,20 +474,29 @@ export const useHomeController = () => {
|
||||
setSelectedSubtitleStyleId,
|
||||
selectedTitleStyleId,
|
||||
setSelectedTitleStyleId,
|
||||
selectedSecondaryTitleStyleId,
|
||||
setSelectedSecondaryTitleStyleId,
|
||||
subtitleFontSize,
|
||||
setSubtitleFontSize,
|
||||
titleFontSize,
|
||||
setTitleFontSize,
|
||||
secondaryTitleFontSize,
|
||||
setSecondaryTitleFontSize,
|
||||
setSubtitleSizeLocked,
|
||||
setTitleSizeLocked,
|
||||
setSecondaryTitleSizeLocked,
|
||||
titleTopMargin,
|
||||
setTitleTopMargin,
|
||||
secondaryTitleTopMargin,
|
||||
setSecondaryTitleTopMargin,
|
||||
titleDisplayMode,
|
||||
setTitleDisplayMode,
|
||||
subtitleBottomMargin,
|
||||
setSubtitleBottomMargin,
|
||||
outputAspectRatio,
|
||||
setOutputAspectRatio,
|
||||
lipsyncModelMode,
|
||||
setLipsyncModelMode,
|
||||
selectedBgmId,
|
||||
setSelectedBgmId,
|
||||
bgmVolume,
|
||||
@@ -469,6 +510,8 @@ export const useHomeController = () => {
|
||||
setSelectedAudioId,
|
||||
speed,
|
||||
setSpeed,
|
||||
emotion,
|
||||
setEmotion,
|
||||
});
|
||||
|
||||
const { savedScripts, saveScript, deleteScript: deleteSavedScript } = useSavedScripts(storageKey);
|
||||
@@ -491,6 +534,12 @@ export const useHomeController = () => {
|
||||
onCommit: syncTitleToPublish,
|
||||
});
|
||||
|
||||
const secondaryTitleInput = useTitleInput({
|
||||
value: videoSecondaryTitle,
|
||||
onChange: setVideoSecondaryTitle,
|
||||
maxLength: SECONDARY_TITLE_MAX_LENGTH,
|
||||
});
|
||||
|
||||
// 加载素材列表和历史视频
|
||||
useEffect(() => {
|
||||
if (isAuthLoading) return;
|
||||
@@ -582,11 +631,32 @@ export const useHomeController = () => {
|
||||
}
|
||||
}, [titleStyles, selectedTitleStyleId, titleSizeLocked]);
|
||||
|
||||
useEffect(() => {
|
||||
if (secondaryTitleSizeLocked || titleStyles.length === 0) return;
|
||||
const active = titleStyles.find((s) => s.id === selectedSecondaryTitleStyleId)
|
||||
|| titleStyles.find((s) => s.is_default)
|
||||
|| titleStyles[0];
|
||||
if (active?.font_size) {
|
||||
setSecondaryTitleFontSize(active.font_size);
|
||||
}
|
||||
}, [titleStyles, selectedSecondaryTitleStyleId, secondaryTitleSizeLocked]);
|
||||
|
||||
// 移除重复的 BGM 持久化恢复逻辑 (已统一移动到 useHomePersistence 中)
|
||||
// useEffect(() => { ... })
|
||||
|
||||
// 时间门控:页面加载后 1 秒内禁止所有列表自动滚动效果
|
||||
// 防止持久化恢复 + 异步数据加载触发 scrollIntoView 导致移动端页面跳动
|
||||
const scrollEffectsEnabled = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!selectedBgmId) return;
|
||||
const timer = setTimeout(() => {
|
||||
scrollEffectsEnabled.current = true;
|
||||
}, 1000);
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
// BGM 列表滚动
|
||||
useEffect(() => {
|
||||
if (!selectedBgmId || !scrollEffectsEnabled.current) return;
|
||||
const container = bgmListContainerRef.current;
|
||||
const target = bgmItemRefs.current[selectedBgmId];
|
||||
if (container && target) {
|
||||
@@ -594,16 +664,10 @@ export const useHomeController = () => {
|
||||
}
|
||||
}, [selectedBgmId, bgmList]);
|
||||
|
||||
// 素材列表滚动:跳过首次恢复,仅用户主动操作时滚动
|
||||
const materialScrollReady = useRef(false);
|
||||
// 素材列表滚动
|
||||
useEffect(() => {
|
||||
const firstSelected = selectedMaterials[0];
|
||||
if (!firstSelected) return;
|
||||
if (!materialScrollReady.current) {
|
||||
// 首次有选中素材时标记就绪,但不滚动(避免刷新后整页跳动)
|
||||
materialScrollReady.current = true;
|
||||
return;
|
||||
}
|
||||
if (!firstSelected || !scrollEffectsEnabled.current) return;
|
||||
const target = materialItemRefs.current[firstSelected];
|
||||
if (target) {
|
||||
target.scrollIntoView({ block: "nearest", behavior: "smooth" });
|
||||
@@ -628,14 +692,9 @@ export const useHomeController = () => {
|
||||
}
|
||||
}, [isRestored, bgmList, selectedBgmId, enableBgm, setSelectedBgmId]);
|
||||
|
||||
const videoScrollReady = useRef(false);
|
||||
// 视频列表滚动
|
||||
useEffect(() => {
|
||||
if (!selectedVideoId) return;
|
||||
if (!videoScrollReady.current) {
|
||||
videoScrollReady.current = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selectedVideoId || !scrollEffectsEnabled.current) return;
|
||||
const target = videoItemRefs.current[selectedVideoId];
|
||||
if (target) {
|
||||
target.scrollIntoView({ block: "nearest", behavior: "smooth" });
|
||||
@@ -676,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[] = [];
|
||||
@@ -689,7 +751,6 @@ export const useHomeController = () => {
|
||||
|
||||
mediaRecorder.start();
|
||||
setIsRecording(true);
|
||||
setRecordingTime(0);
|
||||
mediaRecorderRef.current = mediaRecorder;
|
||||
|
||||
// 计时器
|
||||
@@ -725,6 +786,11 @@ export const useHomeController = () => {
|
||||
setRecordingTime(0);
|
||||
};
|
||||
|
||||
const discardRecording = () => {
|
||||
setRecordedBlob(null);
|
||||
setRecordingTime(0);
|
||||
};
|
||||
|
||||
// 格式化录音时长
|
||||
const formatRecordingTime = (seconds: number) => {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
@@ -741,7 +807,7 @@ export const useHomeController = () => {
|
||||
|
||||
setIsGeneratingMeta(true);
|
||||
try {
|
||||
const { data: res } = await api.post<ApiResponse<{ title?: string; tags?: string[] }>>(
|
||||
const { data: res } = await api.post<ApiResponse<{ title?: string; secondary_title?: string; tags?: string[] }>>(
|
||||
"/api/ai/generate-meta",
|
||||
{ text: text.trim() }
|
||||
);
|
||||
@@ -751,6 +817,10 @@ export const useHomeController = () => {
|
||||
const nextTitle = clampTitle(payload.title || "");
|
||||
titleInput.commitValue(nextTitle);
|
||||
|
||||
// 更新副标题
|
||||
const nextSecondaryTitle = clampSecondaryTitle(payload.secondary_title || "");
|
||||
secondaryTitleInput.commitValue(nextSecondaryTitle);
|
||||
|
||||
// 同步到发布页 localStorage
|
||||
localStorage.setItem(`vigent_${storageKey}_publish_tags`, JSON.stringify(payload.tags || []));
|
||||
} catch (err: unknown) {
|
||||
@@ -823,6 +893,13 @@ export const useHomeController = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const emotionToInstruct: Record<string, string> = {
|
||||
normal: "",
|
||||
happy: "You are a helpful assistant. 请非常开心地说一句话。<|endofprompt|>",
|
||||
sad: "You are a helpful assistant. 请非常伤心地说一句话。<|endofprompt|>",
|
||||
angry: "You are a helpful assistant. 请非常生气地说一句话。<|endofprompt|>",
|
||||
};
|
||||
|
||||
const params = {
|
||||
text: text.trim(),
|
||||
tts_mode: ttsMode,
|
||||
@@ -831,6 +908,7 @@ export const useHomeController = () => {
|
||||
ref_text: ttsMode === "voiceclone" ? refText : undefined,
|
||||
language: textLang,
|
||||
speed: ttsMode === "voiceclone" ? speed : undefined,
|
||||
instruct_text: ttsMode === "voiceclone" ? emotionToInstruct[emotion] || "" : undefined,
|
||||
};
|
||||
await generateAudio(params);
|
||||
};
|
||||
@@ -868,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,
|
||||
@@ -942,19 +1021,33 @@ export const useHomeController = () => {
|
||||
payload.title_font_size = Math.round(titleFontSize);
|
||||
}
|
||||
|
||||
if (videoTitle.trim()) {
|
||||
if (videoTitle.trim() || videoSecondaryTitle.trim()) {
|
||||
payload.title_display_mode = titleDisplayMode;
|
||||
if (titleDisplayMode === "short") {
|
||||
payload.title_duration = DEFAULT_SHORT_TITLE_DURATION;
|
||||
}
|
||||
}
|
||||
|
||||
if (videoTitle.trim()) {
|
||||
payload.title_top_margin = Math.round(titleTopMargin);
|
||||
}
|
||||
|
||||
if (videoSecondaryTitle.trim()) {
|
||||
payload.secondary_title = videoSecondaryTitle.trim();
|
||||
if (selectedSecondaryTitleStyleId) {
|
||||
payload.secondary_title_style_id = selectedSecondaryTitleStyleId;
|
||||
}
|
||||
if (secondaryTitleFontSize) {
|
||||
payload.secondary_title_font_size = Math.round(secondaryTitleFontSize);
|
||||
}
|
||||
payload.secondary_title_top_margin = Math.round(secondaryTitleTopMargin);
|
||||
}
|
||||
|
||||
payload.subtitle_bottom_margin = Math.round(subtitleBottomMargin);
|
||||
|
||||
if (enableBgm && selectedBgmId) {
|
||||
payload.bgm_id = selectedBgmId;
|
||||
payload.bgm_volume = bgmVolume;
|
||||
payload.bgm_volume = 0.2;
|
||||
}
|
||||
|
||||
// 创建生成任务
|
||||
@@ -1030,6 +1123,8 @@ export const useHomeController = () => {
|
||||
setText,
|
||||
extractModalOpen,
|
||||
setExtractModalOpen,
|
||||
rewriteModalOpen,
|
||||
setRewriteModalOpen,
|
||||
handleGenerateMeta,
|
||||
isGeneratingMeta,
|
||||
handleTranslate,
|
||||
@@ -1049,6 +1144,15 @@ export const useHomeController = () => {
|
||||
titleFontSize,
|
||||
setTitleFontSize,
|
||||
setTitleSizeLocked,
|
||||
videoSecondaryTitle,
|
||||
secondaryTitleInput,
|
||||
selectedSecondaryTitleStyleId,
|
||||
setSelectedSecondaryTitleStyleId,
|
||||
secondaryTitleFontSize,
|
||||
setSecondaryTitleFontSize,
|
||||
setSecondaryTitleSizeLocked,
|
||||
secondaryTitleTopMargin,
|
||||
setSecondaryTitleTopMargin,
|
||||
subtitleStyles,
|
||||
selectedSubtitleStyleId,
|
||||
setSelectedSubtitleStyleId,
|
||||
@@ -1063,10 +1167,13 @@ export const useHomeController = () => {
|
||||
setSubtitleBottomMargin,
|
||||
outputAspectRatio,
|
||||
setOutputAspectRatio,
|
||||
lipsyncModelMode,
|
||||
setLipsyncModelMode,
|
||||
resolveAssetUrl,
|
||||
getFontFormat,
|
||||
buildTextShadow,
|
||||
materialDimensions,
|
||||
materialPosterUrl,
|
||||
ttsMode,
|
||||
setTtsMode,
|
||||
voices: VOICES[textLang] || VOICES["zh-CN"],
|
||||
@@ -1098,6 +1205,7 @@ export const useHomeController = () => {
|
||||
startRecording,
|
||||
stopRecording,
|
||||
useRecording,
|
||||
discardRecording,
|
||||
formatRecordingTime,
|
||||
bgmList,
|
||||
bgmLoading,
|
||||
@@ -1136,6 +1244,8 @@ export const useHomeController = () => {
|
||||
selectAudio,
|
||||
speed,
|
||||
setSpeed,
|
||||
emotion,
|
||||
setEmotion,
|
||||
timelineSegments,
|
||||
reorderSegments,
|
||||
setSourceRange,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { clampTitle } from "@/shared/lib/title";
|
||||
import { clampTitle, clampSecondaryTitle } from "@/shared/lib/title";
|
||||
|
||||
interface RefAudio {
|
||||
id: string;
|
||||
@@ -17,6 +17,8 @@ interface UseHomePersistenceOptions {
|
||||
setText: React.Dispatch<React.SetStateAction<string>>;
|
||||
videoTitle: string;
|
||||
setVideoTitle: React.Dispatch<React.SetStateAction<string>>;
|
||||
videoSecondaryTitle: string;
|
||||
setVideoSecondaryTitle: React.Dispatch<React.SetStateAction<string>>;
|
||||
ttsMode: 'edgetts' | 'voiceclone';
|
||||
setTtsMode: React.Dispatch<React.SetStateAction<'edgetts' | 'voiceclone'>>;
|
||||
voice: string;
|
||||
@@ -29,20 +31,29 @@ interface UseHomePersistenceOptions {
|
||||
setSelectedSubtitleStyleId: React.Dispatch<React.SetStateAction<string>>;
|
||||
selectedTitleStyleId: string;
|
||||
setSelectedTitleStyleId: React.Dispatch<React.SetStateAction<string>>;
|
||||
selectedSecondaryTitleStyleId: string;
|
||||
setSelectedSecondaryTitleStyleId: React.Dispatch<React.SetStateAction<string>>;
|
||||
subtitleFontSize: number;
|
||||
setSubtitleFontSize: React.Dispatch<React.SetStateAction<number>>;
|
||||
titleFontSize: number;
|
||||
setTitleFontSize: React.Dispatch<React.SetStateAction<number>>;
|
||||
secondaryTitleFontSize: number;
|
||||
setSecondaryTitleFontSize: React.Dispatch<React.SetStateAction<number>>;
|
||||
setSubtitleSizeLocked: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
setTitleSizeLocked: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
setSecondaryTitleSizeLocked: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
titleTopMargin: number;
|
||||
setTitleTopMargin: React.Dispatch<React.SetStateAction<number>>;
|
||||
secondaryTitleTopMargin: number;
|
||||
setSecondaryTitleTopMargin: React.Dispatch<React.SetStateAction<number>>;
|
||||
titleDisplayMode: 'short' | 'persistent';
|
||||
setTitleDisplayMode: React.Dispatch<React.SetStateAction<'short' | 'persistent'>>;
|
||||
subtitleBottomMargin: number;
|
||||
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;
|
||||
@@ -56,6 +67,8 @@ interface UseHomePersistenceOptions {
|
||||
setSelectedAudioId: React.Dispatch<React.SetStateAction<string | null>>;
|
||||
speed: number;
|
||||
setSpeed: React.Dispatch<React.SetStateAction<number>>;
|
||||
emotion: string;
|
||||
setEmotion: React.Dispatch<React.SetStateAction<string>>;
|
||||
}
|
||||
|
||||
export const useHomePersistence = ({
|
||||
@@ -65,6 +78,8 @@ export const useHomePersistence = ({
|
||||
setText,
|
||||
videoTitle,
|
||||
setVideoTitle,
|
||||
videoSecondaryTitle,
|
||||
setVideoSecondaryTitle,
|
||||
ttsMode,
|
||||
setTtsMode,
|
||||
voice,
|
||||
@@ -77,20 +92,29 @@ export const useHomePersistence = ({
|
||||
setSelectedSubtitleStyleId,
|
||||
selectedTitleStyleId,
|
||||
setSelectedTitleStyleId,
|
||||
selectedSecondaryTitleStyleId,
|
||||
setSelectedSecondaryTitleStyleId,
|
||||
subtitleFontSize,
|
||||
setSubtitleFontSize,
|
||||
titleFontSize,
|
||||
setTitleFontSize,
|
||||
secondaryTitleFontSize,
|
||||
setSecondaryTitleFontSize,
|
||||
setSubtitleSizeLocked,
|
||||
setTitleSizeLocked,
|
||||
setSecondaryTitleSizeLocked,
|
||||
titleTopMargin,
|
||||
setTitleTopMargin,
|
||||
secondaryTitleTopMargin,
|
||||
setSecondaryTitleTopMargin,
|
||||
titleDisplayMode,
|
||||
setTitleDisplayMode,
|
||||
subtitleBottomMargin,
|
||||
setSubtitleBottomMargin,
|
||||
outputAspectRatio,
|
||||
setOutputAspectRatio,
|
||||
lipsyncModelMode,
|
||||
setLipsyncModelMode,
|
||||
selectedBgmId,
|
||||
setSelectedBgmId,
|
||||
bgmVolume,
|
||||
@@ -104,6 +128,8 @@ export const useHomePersistence = ({
|
||||
setSelectedAudioId,
|
||||
speed,
|
||||
setSpeed,
|
||||
emotion,
|
||||
setEmotion,
|
||||
}: UseHomePersistenceOptions) => {
|
||||
const [isRestored, setIsRestored] = useState(false);
|
||||
|
||||
@@ -112,27 +138,35 @@ export const useHomePersistence = ({
|
||||
|
||||
const savedText = localStorage.getItem(`vigent_${storageKey}_text`);
|
||||
const savedTitle = localStorage.getItem(`vigent_${storageKey}_title`);
|
||||
const savedSecondaryTitle = localStorage.getItem(`vigent_${storageKey}_secondaryTitle`);
|
||||
const savedTtsMode = localStorage.getItem(`vigent_${storageKey}_ttsMode`);
|
||||
const savedVoice = localStorage.getItem(`vigent_${storageKey}_voice`);
|
||||
const savedTextLang = localStorage.getItem(`vigent_${storageKey}_textLang`);
|
||||
const savedMaterial = localStorage.getItem(`vigent_${storageKey}_material`);
|
||||
const savedSubtitleStyle = localStorage.getItem(`vigent_${storageKey}_subtitleStyle`);
|
||||
const savedTitleStyle = localStorage.getItem(`vigent_${storageKey}_titleStyle`);
|
||||
const savedSecondaryTitleStyle = localStorage.getItem(`vigent_${storageKey}_secondaryTitleStyle`);
|
||||
const savedSubtitleFontSize = localStorage.getItem(`vigent_${storageKey}_subtitleFontSize`);
|
||||
const savedTitleFontSize = localStorage.getItem(`vigent_${storageKey}_titleFontSize`);
|
||||
const savedSecondaryTitleFontSize = localStorage.getItem(`vigent_${storageKey}_secondaryTitleFontSize`);
|
||||
const savedBgmId = localStorage.getItem(`vigent_${storageKey}_bgmId`);
|
||||
const savedSelectedVideoId = localStorage.getItem(`vigent_${storageKey}_selectedVideoId`);
|
||||
const savedSelectedVideoId = localStorage.getItem(`vigent_${storageKey}_latestGeneratedVideoId`)
|
||||
|| localStorage.getItem(`vigent_${storageKey}_selectedVideoId`);
|
||||
const savedSelectedAudioId = localStorage.getItem(`vigent_${storageKey}_selectedAudioId`);
|
||||
const savedBgmVolume = localStorage.getItem(`vigent_${storageKey}_bgmVolume`);
|
||||
const savedEnableBgm = localStorage.getItem(`vigent_${storageKey}_enableBgm`);
|
||||
const savedTitleTopMargin = localStorage.getItem(`vigent_${storageKey}_titleTopMargin`);
|
||||
const savedSecondaryTitleTopMargin = localStorage.getItem(`vigent_${storageKey}_secondaryTitleTopMargin`);
|
||||
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`);
|
||||
|
||||
setText(savedText || "大家好,欢迎来到我的频道,今天给大家分享一些有趣的内容。");
|
||||
setVideoTitle(savedTitle ? clampTitle(savedTitle) : "");
|
||||
setVideoSecondaryTitle(savedSecondaryTitle ? clampSecondaryTitle(savedSecondaryTitle) : "");
|
||||
setTtsMode((savedTtsMode as 'edgetts' | 'voiceclone') || 'edgetts');
|
||||
setVoice(savedVoice || "zh-CN-YunxiNeural");
|
||||
if (savedTextLang) setTextLang(savedTextLang);
|
||||
@@ -152,6 +186,7 @@ export const useHomePersistence = ({
|
||||
}
|
||||
if (savedSubtitleStyle) setSelectedSubtitleStyleId(savedSubtitleStyle);
|
||||
if (savedTitleStyle) setSelectedTitleStyleId(savedTitleStyle);
|
||||
if (savedSecondaryTitleStyle) setSelectedSecondaryTitleStyleId(savedSecondaryTitleStyle);
|
||||
|
||||
if (savedSubtitleFontSize) {
|
||||
const parsed = parseInt(savedSubtitleFontSize, 10);
|
||||
@@ -169,16 +204,30 @@ export const useHomePersistence = ({
|
||||
}
|
||||
}
|
||||
|
||||
if (savedSecondaryTitleFontSize) {
|
||||
const parsed = parseInt(savedSecondaryTitleFontSize, 10);
|
||||
if (!Number.isNaN(parsed)) {
|
||||
setSecondaryTitleFontSize(parsed);
|
||||
setSecondaryTitleSizeLocked(true);
|
||||
}
|
||||
}
|
||||
|
||||
if (savedBgmId) setSelectedBgmId(savedBgmId);
|
||||
if (savedBgmVolume) setBgmVolume(parseFloat(savedBgmVolume));
|
||||
if (savedEnableBgm !== null) setEnableBgm(savedEnableBgm === 'true');
|
||||
if (savedSelectedVideoId) setSelectedVideoId(savedSelectedVideoId);
|
||||
// 消费后清除跨页面共享标记,避免反复覆盖
|
||||
localStorage.removeItem(`vigent_${storageKey}_latestGeneratedVideoId`);
|
||||
if (savedSelectedAudioId) setSelectedAudioId(savedSelectedAudioId);
|
||||
|
||||
if (savedTitleTopMargin) {
|
||||
const parsed = parseInt(savedTitleTopMargin, 10);
|
||||
if (!Number.isNaN(parsed)) setTitleTopMargin(parsed);
|
||||
}
|
||||
if (savedSecondaryTitleTopMargin) {
|
||||
const parsed = parseInt(savedSecondaryTitleTopMargin, 10);
|
||||
if (!Number.isNaN(parsed)) setSecondaryTitleTopMargin(parsed);
|
||||
}
|
||||
if (savedTitleDisplayMode === 'short' || savedTitleDisplayMode === 'persistent') {
|
||||
setTitleDisplayMode(savedTitleDisplayMode);
|
||||
}
|
||||
@@ -191,11 +240,21 @@ 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);
|
||||
}
|
||||
|
||||
if (savedEmotion) setEmotion(savedEmotion);
|
||||
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setIsRestored(true);
|
||||
}, [
|
||||
@@ -206,21 +265,28 @@ export const useHomePersistence = ({
|
||||
setSelectedMaterials,
|
||||
setSelectedSubtitleStyleId,
|
||||
setSelectedTitleStyleId,
|
||||
setSelectedSecondaryTitleStyleId,
|
||||
setSelectedVideoId,
|
||||
setSelectedAudioId,
|
||||
setSpeed,
|
||||
setEmotion,
|
||||
setSubtitleFontSize,
|
||||
setSubtitleSizeLocked,
|
||||
setText,
|
||||
setTextLang,
|
||||
setTitleFontSize,
|
||||
setTitleSizeLocked,
|
||||
setSecondaryTitleFontSize,
|
||||
setSecondaryTitleSizeLocked,
|
||||
setTitleTopMargin,
|
||||
setSecondaryTitleTopMargin,
|
||||
setTitleDisplayMode,
|
||||
setSubtitleBottomMargin,
|
||||
setOutputAspectRatio,
|
||||
setLipsyncModelMode,
|
||||
setTtsMode,
|
||||
setVideoTitle,
|
||||
setVideoSecondaryTitle,
|
||||
setVoice,
|
||||
storageKey,
|
||||
]);
|
||||
@@ -241,6 +307,14 @@ export const useHomePersistence = ({
|
||||
return () => clearTimeout(timeout);
|
||||
}, [videoTitle, storageKey, isRestored]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isRestored) return;
|
||||
const timeout = setTimeout(() => {
|
||||
localStorage.setItem(`vigent_${storageKey}_secondaryTitle`, videoSecondaryTitle);
|
||||
}, 300);
|
||||
return () => clearTimeout(timeout);
|
||||
}, [videoSecondaryTitle, storageKey, isRestored]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isRestored) localStorage.setItem(`vigent_${storageKey}_ttsMode`, ttsMode);
|
||||
}, [ttsMode, storageKey, isRestored]);
|
||||
@@ -271,6 +345,12 @@ export const useHomePersistence = ({
|
||||
}
|
||||
}, [selectedTitleStyleId, storageKey, isRestored]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isRestored && selectedSecondaryTitleStyleId) {
|
||||
localStorage.setItem(`vigent_${storageKey}_secondaryTitleStyle`, selectedSecondaryTitleStyleId);
|
||||
}
|
||||
}, [selectedSecondaryTitleStyleId, storageKey, isRestored]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isRestored) {
|
||||
localStorage.setItem(`vigent_${storageKey}_subtitleFontSize`, String(subtitleFontSize));
|
||||
@@ -283,12 +363,24 @@ export const useHomePersistence = ({
|
||||
}
|
||||
}, [titleFontSize, storageKey, isRestored]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isRestored) {
|
||||
localStorage.setItem(`vigent_${storageKey}_secondaryTitleFontSize`, String(secondaryTitleFontSize));
|
||||
}
|
||||
}, [secondaryTitleFontSize, storageKey, isRestored]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isRestored) {
|
||||
localStorage.setItem(`vigent_${storageKey}_titleTopMargin`, String(titleTopMargin));
|
||||
}
|
||||
}, [titleTopMargin, storageKey, isRestored]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isRestored) {
|
||||
localStorage.setItem(`vigent_${storageKey}_secondaryTitleTopMargin`, String(secondaryTitleTopMargin));
|
||||
}
|
||||
}, [secondaryTitleTopMargin, storageKey, isRestored]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isRestored) {
|
||||
localStorage.setItem(`vigent_${storageKey}_titleDisplayMode`, titleDisplayMode);
|
||||
@@ -307,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);
|
||||
@@ -357,5 +455,11 @@ export const useHomePersistence = ({
|
||||
}
|
||||
}, [speed, storageKey, isRestored]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isRestored) {
|
||||
localStorage.setItem(`vigent_${storageKey}_emotion`, emotion);
|
||||
}
|
||||
}, [emotion, storageKey, isRestored]);
|
||||
|
||||
return { isRestored };
|
||||
};
|
||||
|
||||
93
frontend/src/features/home/model/useVideoFrameCapture.ts
Normal file
93
frontend/src/features/home/model/useVideoFrameCapture.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
/** 预览窗口最大 280px 宽,截取无需超过此尺寸 */
|
||||
const MAX_CAPTURE_WIDTH = 480;
|
||||
|
||||
/**
|
||||
* 从视频 URL 截取 0.1s 处的帧,返回 JPEG data URL。
|
||||
* 失败时返回 null(降级渐变背景)。
|
||||
*/
|
||||
export function useVideoFrameCapture(videoUrl: string | null): string | null {
|
||||
const [frameUrl, setFrameUrl] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!videoUrl) {
|
||||
setFrameUrl(null);
|
||||
return;
|
||||
}
|
||||
|
||||
let isActive = true;
|
||||
const video = document.createElement("video");
|
||||
video.muted = true;
|
||||
video.preload = "auto";
|
||||
video.playsInline = true;
|
||||
|
||||
const cleanup = () => {
|
||||
video.removeEventListener("loadedmetadata", onLoaded);
|
||||
video.removeEventListener("canplay", onLoaded);
|
||||
video.removeEventListener("seeked", onSeeked);
|
||||
video.removeEventListener("error", onError);
|
||||
video.src = "";
|
||||
video.load();
|
||||
};
|
||||
|
||||
const onSeeked = () => {
|
||||
if (!isActive) return;
|
||||
try {
|
||||
const vw = video.videoWidth;
|
||||
const vh = video.videoHeight;
|
||||
if (!vw || !vh) {
|
||||
if (isActive) setFrameUrl(null);
|
||||
cleanup();
|
||||
return;
|
||||
}
|
||||
|
||||
const scale = Math.min(1, MAX_CAPTURE_WIDTH / vw);
|
||||
const cw = Math.round(vw * scale);
|
||||
const ch = Math.round(vh * scale);
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = cw;
|
||||
canvas.height = ch;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) {
|
||||
if (isActive) setFrameUrl(null);
|
||||
cleanup();
|
||||
return;
|
||||
}
|
||||
ctx.drawImage(video, 0, 0, cw, ch);
|
||||
const dataUrl = canvas.toDataURL("image/jpeg", 0.7);
|
||||
if (isActive) setFrameUrl(dataUrl);
|
||||
} catch {
|
||||
if (isActive) setFrameUrl(null);
|
||||
}
|
||||
cleanup();
|
||||
};
|
||||
|
||||
let seeked = false;
|
||||
const onLoaded = () => {
|
||||
if (!isActive || seeked) return;
|
||||
seeked = true;
|
||||
video.currentTime = 0.1;
|
||||
};
|
||||
|
||||
const onError = () => {
|
||||
if (isActive) setFrameUrl(null);
|
||||
cleanup();
|
||||
};
|
||||
|
||||
// 先绑定监听,再设 src
|
||||
video.addEventListener("loadedmetadata", onLoaded);
|
||||
video.addEventListener("canplay", onLoaded);
|
||||
video.addEventListener("seeked", onSeeked);
|
||||
video.addEventListener("error", onError);
|
||||
video.src = videoUrl;
|
||||
|
||||
return () => {
|
||||
isActive = false;
|
||||
cleanup();
|
||||
};
|
||||
}, [videoUrl]);
|
||||
|
||||
return frameUrl;
|
||||
}
|
||||
@@ -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,15 +34,35 @@ 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">
|
||||
<h2 className="text-lg font-semibold text-white flex items-center gap-2">🎵 背景音乐</h2>
|
||||
<h2 className="text-lg font-semibold text-white flex items-center gap-2">五、背景音乐</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={onRefresh}
|
||||
@@ -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">
|
||||
@@ -213,7 +211,7 @@ export function ClipTrimmer({
|
||||
{/* Custom range track */}
|
||||
<div
|
||||
ref={trackRef}
|
||||
className="relative h-8 cursor-pointer select-none touch-none"
|
||||
className="relative h-10 cursor-pointer select-none touch-none"
|
||||
onPointerMove={handleTrackPointerMove}
|
||||
onPointerUp={handleTrackPointerUp}
|
||||
onPointerLeave={handleTrackPointerUp}
|
||||
@@ -242,7 +240,7 @@ export function ClipTrimmer({
|
||||
{/* Start thumb */}
|
||||
<div
|
||||
onPointerDown={(e) => handleThumbPointerDown("start", e)}
|
||||
className="absolute top-1/2 -translate-y-1/2 -translate-x-1/2 w-4 h-4 rounded-full bg-purple-500 border-2 border-white shadow-lg cursor-grab active:cursor-grabbing hover:scale-110 transition-transform z-10"
|
||||
className="absolute top-1/2 -translate-y-1/2 -translate-x-1/2 w-5 h-5 rounded-full bg-purple-500 border-2 border-white shadow-lg cursor-grab active:cursor-grabbing hover:scale-110 transition-transform z-10"
|
||||
style={{ left: `${startPct}%` }}
|
||||
title={`起点: ${formatSec(sourceStart)}`}
|
||||
/>
|
||||
@@ -250,7 +248,7 @@ export function ClipTrimmer({
|
||||
{/* End thumb */}
|
||||
<div
|
||||
onPointerDown={(e) => handleThumbPointerDown("end", e)}
|
||||
className="absolute top-1/2 -translate-y-1/2 -translate-x-1/2 w-4 h-4 rounded-full bg-pink-500 border-2 border-white shadow-lg cursor-grab active:cursor-grabbing hover:scale-110 transition-transform z-10"
|
||||
className="absolute top-1/2 -translate-y-1/2 -translate-x-1/2 w-5 h-5 rounded-full bg-pink-500 border-2 border-white shadow-lg cursor-grab active:cursor-grabbing hover:scale-110 transition-transform z-10"
|
||||
style={{ left: `${endPct}%` }}
|
||||
title={`终点: ${formatSec(effectiveEnd)}`}
|
||||
/>
|
||||
@@ -287,7 +285,6 @@ export function ClipTrimmer({
|
||||
确定
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
</AppModal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -35,9 +35,13 @@ interface TitleStyleOption {
|
||||
interface FloatingStylePreviewProps {
|
||||
onClose: () => void;
|
||||
videoTitle: string;
|
||||
videoSecondaryTitle: string;
|
||||
titleStyles: TitleStyleOption[];
|
||||
selectedTitleStyleId: string;
|
||||
titleFontSize: number;
|
||||
selectedSecondaryTitleStyleId: string;
|
||||
secondaryTitleFontSize: number;
|
||||
secondaryTitleTopMargin: number;
|
||||
subtitleStyles: SubtitleStyleOption[];
|
||||
selectedSubtitleStyleId: string;
|
||||
subtitleFontSize: number;
|
||||
@@ -49,16 +53,22 @@ interface FloatingStylePreviewProps {
|
||||
buildTextShadow: (color: string, size: number) => string;
|
||||
previewBaseWidth: number;
|
||||
previewBaseHeight: number;
|
||||
previewBackgroundUrl?: string | null;
|
||||
}
|
||||
|
||||
const DESKTOP_WIDTH = 280;
|
||||
const MOBILE_WIDTH = 160;
|
||||
|
||||
export function FloatingStylePreview({
|
||||
onClose,
|
||||
videoTitle,
|
||||
videoSecondaryTitle,
|
||||
titleStyles,
|
||||
selectedTitleStyleId,
|
||||
titleFontSize,
|
||||
selectedSecondaryTitleStyleId,
|
||||
secondaryTitleFontSize,
|
||||
secondaryTitleTopMargin,
|
||||
subtitleStyles,
|
||||
selectedSubtitleStyleId,
|
||||
subtitleFontSize,
|
||||
@@ -70,11 +80,10 @@ export function FloatingStylePreview({
|
||||
buildTextShadow,
|
||||
previewBaseWidth,
|
||||
previewBaseHeight,
|
||||
previewBackgroundUrl,
|
||||
}: FloatingStylePreviewProps) {
|
||||
const isMobile = typeof window !== "undefined" && window.innerWidth < 640;
|
||||
const windowWidth = isMobile
|
||||
? Math.min(window.innerWidth - 32, 360)
|
||||
: DESKTOP_WIDTH;
|
||||
const windowWidth = isMobile ? MOBILE_WIDTH : DESKTOP_WIDTH;
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
@@ -126,15 +135,32 @@ export function FloatingStylePreview({
|
||||
const scaledTitleTopMargin = Math.max(0, Math.round(titleTopMargin * responsiveScale));
|
||||
const scaledSubtitleBottomMargin = Math.max(0, Math.round(subtitleBottomMargin * responsiveScale));
|
||||
|
||||
// 副标题样式
|
||||
const activeSecondaryTitleStyle = titleStyles.find((s) => s.id === selectedSecondaryTitleStyleId)
|
||||
|| activeTitleStyle;
|
||||
const stColor = activeSecondaryTitleStyle?.color || "#FFFFFF";
|
||||
const stStrokeColor = activeSecondaryTitleStyle?.stroke_color || "#000000";
|
||||
const stStrokeSize = Math.max(1, Math.round((activeSecondaryTitleStyle?.stroke_size ?? 6) * responsiveScale));
|
||||
const stLetterSpacing = Math.max(0, (activeSecondaryTitleStyle?.letter_spacing ?? 2) * responsiveScale);
|
||||
const stFontWeight = activeSecondaryTitleStyle?.font_weight ?? 700;
|
||||
const stFontFamilyName = `SecondaryTitlePreview-${activeSecondaryTitleStyle?.id || "default"}`;
|
||||
const stFontUrl = activeSecondaryTitleStyle?.font_file
|
||||
? resolveAssetUrl(`fonts/${activeSecondaryTitleStyle.font_file}`)
|
||||
: null;
|
||||
const scaledSecondaryTitleFontSize = Math.max(24, Math.round(secondaryTitleFontSize * responsiveScale));
|
||||
const scaledSecondaryTitleTopMargin = Math.max(0, Math.round(secondaryTitleTopMargin * responsiveScale));
|
||||
const previewSecondaryTitleText = videoSecondaryTitle.trim() || "";
|
||||
|
||||
const content = (
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
left: "16px",
|
||||
top: "16px",
|
||||
...(isMobile
|
||||
? { right: "12px", bottom: "12px" }
|
||||
: { left: "16px", top: "16px" }),
|
||||
width: `${windowWidth}px`,
|
||||
zIndex: 150,
|
||||
maxHeight: "calc(100dvh - 32px)",
|
||||
maxHeight: isMobile ? "calc(50dvh)" : "calc(100dvh - 32px)",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
className="rounded-xl border border-white/20 bg-gray-900/95 backdrop-blur-md shadow-2xl"
|
||||
@@ -159,13 +185,18 @@ export function FloatingStylePreview({
|
||||
className="relative overflow-hidden rounded-b-xl"
|
||||
style={{ height: `${previewHeight}px` }}
|
||||
>
|
||||
{(titleFontUrl || subtitleFontUrl) && (
|
||||
{(titleFontUrl || subtitleFontUrl || stFontUrl) && (
|
||||
<style>{`
|
||||
${titleFontUrl ? `@font-face { font-family: '${titleFontFamilyName}'; src: url('${titleFontUrl}') format('${getFontFormat(activeTitleStyle?.font_file)}'); font-weight: 400; font-style: normal; }` : ''}
|
||||
${stFontUrl && stFontUrl !== titleFontUrl ? `@font-face { font-family: '${stFontFamilyName}'; src: url('${stFontUrl}') format('${getFontFormat(activeSecondaryTitleStyle?.font_file)}'); font-weight: 400; font-style: normal; }` : ''}
|
||||
${subtitleFontUrl ? `@font-face { font-family: '${subtitleFontFamilyName}'; src: url('${subtitleFontUrl}') format('${getFontFormat(activeSubtitleStyle?.font_file)}'); font-weight: 400; font-style: normal; }` : ''}
|
||||
`}</style>
|
||||
)}
|
||||
<div className="absolute inset-0 opacity-20 bg-gradient-to-br from-purple-500/40 via-transparent to-pink-500/30" />
|
||||
{previewBackgroundUrl ? (
|
||||
<img src={previewBackgroundUrl} alt="" className="absolute inset-0 w-full h-full object-cover" />
|
||||
) : (
|
||||
<div className="absolute inset-0 opacity-20 bg-gradient-to-br from-purple-500/40 via-transparent to-pink-500/30" />
|
||||
)}
|
||||
<div
|
||||
className="absolute top-0 left-0"
|
||||
style={{
|
||||
@@ -182,24 +213,55 @@ export function FloatingStylePreview({
|
||||
top: `${scaledTitleTopMargin}px`,
|
||||
left: 0,
|
||||
right: 0,
|
||||
color: titleColor,
|
||||
fontSize: `${scaledTitleFontSize}px`,
|
||||
fontWeight: titleFontWeight,
|
||||
fontFamily: titleFontUrl
|
||||
? `'${titleFontFamilyName}', "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans SC", sans-serif`
|
||||
: '"PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans SC", sans-serif',
|
||||
textShadow: buildTextShadow(titleStrokeColor, titleStrokeSize),
|
||||
letterSpacing: `${titleLetterSpacing}px`,
|
||||
lineHeight: 1.2,
|
||||
whiteSpace: 'normal',
|
||||
wordBreak: 'break-word',
|
||||
overflowWrap: 'anywhere',
|
||||
boxSizing: 'border-box',
|
||||
opacity: videoTitle.trim() ? 1 : 0.7,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
padding: '0 5%',
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
>
|
||||
{previewTitleText}
|
||||
<div
|
||||
style={{
|
||||
color: titleColor,
|
||||
fontSize: `${scaledTitleFontSize}px`,
|
||||
fontWeight: titleFontWeight,
|
||||
fontFamily: titleFontUrl
|
||||
? `'${titleFontFamilyName}', "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans SC", sans-serif`
|
||||
: '"PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans SC", sans-serif',
|
||||
textShadow: buildTextShadow(titleStrokeColor, titleStrokeSize),
|
||||
letterSpacing: `${titleLetterSpacing}px`,
|
||||
lineHeight: 1.2,
|
||||
whiteSpace: 'normal',
|
||||
wordBreak: 'break-word',
|
||||
overflowWrap: 'anywhere',
|
||||
opacity: videoTitle.trim() ? 1 : 0.7,
|
||||
}}
|
||||
>
|
||||
{previewTitleText}
|
||||
</div>
|
||||
{previewSecondaryTitleText && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: `${scaledSecondaryTitleTopMargin}px`,
|
||||
color: stColor,
|
||||
fontSize: `${scaledSecondaryTitleFontSize}px`,
|
||||
fontWeight: stFontWeight,
|
||||
fontFamily: stFontUrl && stFontUrl !== titleFontUrl
|
||||
? `'${stFontFamilyName}', "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans SC", sans-serif`
|
||||
: titleFontUrl
|
||||
? `'${titleFontFamilyName}', "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans SC", sans-serif`
|
||||
: '"PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans SC", sans-serif',
|
||||
textShadow: buildTextShadow(stStrokeColor, stStrokeSize),
|
||||
letterSpacing: `${stLetterSpacing}px`,
|
||||
lineHeight: 1.2,
|
||||
whiteSpace: 'normal',
|
||||
wordBreak: 'break-word',
|
||||
overflowWrap: 'anywhere',
|
||||
}}
|
||||
>
|
||||
{previewSecondaryTitleText}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
|
||||
@@ -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;
|
||||
@@ -23,6 +24,9 @@ interface GeneratedAudiosPanelProps {
|
||||
speed: number;
|
||||
onSpeedChange: (speed: number) => void;
|
||||
ttsMode: string;
|
||||
emotion: string;
|
||||
onEmotionChange: (e: string) => void;
|
||||
embedded?: boolean;
|
||||
}
|
||||
|
||||
export function GeneratedAudiosPanel({
|
||||
@@ -40,13 +44,16 @@ export function GeneratedAudiosPanel({
|
||||
speed,
|
||||
onSpeedChange,
|
||||
ttsMode,
|
||||
emotion,
|
||||
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 audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const speedRef = 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) {
|
||||
@@ -67,17 +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]);
|
||||
|
||||
const togglePlay = (audio: GeneratedAudio, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (playingId === audio.id) {
|
||||
@@ -123,64 +119,259 @@ export function GeneratedAudiosPanel({
|
||||
] as const;
|
||||
const currentSpeedLabel = speedOptions.find((o) => o.value === speed)?.label ?? "正常";
|
||||
|
||||
return (
|
||||
<div className="bg-white/5 rounded-2xl p-4 sm:p-6 border border-white/10 backdrop-blur-sm relative z-10">
|
||||
<div className="flex justify-between items-center gap-2 mb-4">
|
||||
<h2 className="text-base sm:text-lg font-semibold text-white flex items-center gap-2 whitespace-nowrap">
|
||||
<Mic className="h-4 w-4 text-purple-400" />
|
||||
配音列表
|
||||
</h2>
|
||||
<div className="flex gap-1.5">
|
||||
{/* 语速下拉 (仅声音克隆模式) */}
|
||||
{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>
|
||||
)}
|
||||
<button
|
||||
onClick={onGenerateAudio}
|
||||
disabled={isGeneratingAudio || !canGenerate}
|
||||
title={missingRefAudio ? "请先选择参考音频" : !hasText ? "请先输入文案" : ""}
|
||||
className={`px-2 py-1 text-xs rounded transition-all whitespace-nowrap flex items-center gap-1 ${
|
||||
isGeneratingAudio || !canGenerate
|
||||
? "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"
|
||||
}`}
|
||||
>
|
||||
<Mic className="h-3.5 w-3.5" />
|
||||
生成配音
|
||||
</button>
|
||||
<button
|
||||
onClick={onRefresh}
|
||||
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"
|
||||
>
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
const emotionOptions = [
|
||||
{ value: "normal", label: "正常" },
|
||||
{ value: "happy", label: "欢快" },
|
||||
{ value: "sad", label: "低沉" },
|
||||
{ value: "angry", label: "严肃" },
|
||||
] as const;
|
||||
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 = (
|
||||
<>
|
||||
{embedded ? (
|
||||
<>
|
||||
{/* Row 1: 语气 + 语速 + 生成配音 (right-aligned) */}
|
||||
<div className="flex justify-end items-center gap-1.5 mb-3">
|
||||
{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}
|
||||
title={missingRefAudio ? "请先选择参考音频" : !hasText ? "请先输入文案" : ""}
|
||||
className={`px-4 py-2 text-sm font-medium rounded-lg transition-all whitespace-nowrap flex items-center gap-1.5 shadow-sm ${
|
||||
isGeneratingAudio || !canGenerate
|
||||
? "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 hover:shadow-md"
|
||||
}`}
|
||||
>
|
||||
<Mic className="h-4 w-4" />
|
||||
生成配音
|
||||
</button>
|
||||
</div>
|
||||
{/* Row 2: 配音列表 + 刷新 */}
|
||||
<div className="flex justify-between items-center mb-3">
|
||||
<h3 className="text-sm font-medium text-gray-400">配音列表</h3>
|
||||
<button
|
||||
onClick={onRefresh}
|
||||
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"
|
||||
>
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex justify-between items-center gap-2 mb-4">
|
||||
<h2 className="text-base sm:text-lg font-semibold text-white flex items-center gap-2 whitespace-nowrap">
|
||||
<Mic className="h-4 w-4 text-purple-400" />
|
||||
配音列表
|
||||
</h2>
|
||||
<div className="flex gap-1.5">
|
||||
{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}
|
||||
title={missingRefAudio ? "请先选择参考音频" : !hasText ? "请先输入文案" : ""}
|
||||
className={`px-4 py-2 text-sm font-medium rounded-lg transition-all whitespace-nowrap flex items-center gap-1.5 shadow-sm ${
|
||||
isGeneratingAudio || !canGenerate
|
||||
? "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 hover:shadow-md"
|
||||
}`}
|
||||
>
|
||||
<Mic className="h-4 w-4" />
|
||||
生成配音
|
||||
</button>
|
||||
<button
|
||||
onClick={onRefresh}
|
||||
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"
|
||||
>
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 缺少参考音频提示 */}
|
||||
{missingRefAudio && (
|
||||
@@ -206,88 +397,150 @@ 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-0 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
if (embedded) return content;
|
||||
|
||||
return (
|
||||
<div className="bg-white/5 rounded-2xl p-4 sm:p-6 border border-white/10 backdrop-blur-sm relative z-10">
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -16,6 +18,7 @@ interface HistoryListProps {
|
||||
onRefresh: () => void;
|
||||
registerVideoRef: (id: string, element: HTMLDivElement | null) => void;
|
||||
formatDate: (timestamp: number) => string;
|
||||
embedded?: boolean;
|
||||
}
|
||||
|
||||
export function HistoryList({
|
||||
@@ -26,55 +29,151 @@ export function HistoryList({
|
||||
onRefresh,
|
||||
registerVideoRef,
|
||||
formatDate,
|
||||
embedded = false,
|
||||
}: HistoryListProps) {
|
||||
return (
|
||||
<div className="bg-white/5 rounded-2xl p-6 border border-white/10 backdrop-blur-sm">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-lg font-semibold text-white flex items-center gap-2">📂 历史作品</h2>
|
||||
<button
|
||||
onClick={onRefresh}
|
||||
className="px-3 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>
|
||||
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 && (
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-lg font-semibold text-white flex items-center gap-2">历史作品</h2>
|
||||
<button
|
||||
onClick={onRefresh}
|
||||
className="px-3 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>
|
||||
)}
|
||||
{generatedVideos.length === 0 ? (
|
||||
<div className="text-center py-4 text-gray-500">
|
||||
<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-0 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
if (embedded) return content;
|
||||
|
||||
return (
|
||||
<div className="bg-white/5 rounded-2xl p-6 border border-white/10 backdrop-blur-sm">
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { RefreshCw } from "lucide-react";
|
||||
import VideoPreviewModal from "@/components/VideoPreviewModal";
|
||||
import ScriptExtractionModal from "./ScriptExtractionModal";
|
||||
import RewriteModal from "./RewriteModal";
|
||||
import { useHomeController } from "@/features/home/model/useHomeController";
|
||||
import { resolveMediaUrl } from "@/shared/lib/media";
|
||||
import { BgmPanel } from "@/features/home/ui/BgmPanel";
|
||||
@@ -51,6 +53,8 @@ export function HomePage() {
|
||||
setText,
|
||||
extractModalOpen,
|
||||
setExtractModalOpen,
|
||||
rewriteModalOpen,
|
||||
setRewriteModalOpen,
|
||||
handleGenerateMeta,
|
||||
isGeneratingMeta,
|
||||
handleTranslate,
|
||||
@@ -70,6 +74,15 @@ export function HomePage() {
|
||||
titleFontSize,
|
||||
setTitleFontSize,
|
||||
setTitleSizeLocked,
|
||||
videoSecondaryTitle,
|
||||
secondaryTitleInput,
|
||||
selectedSecondaryTitleStyleId,
|
||||
setSelectedSecondaryTitleStyleId,
|
||||
secondaryTitleFontSize,
|
||||
setSecondaryTitleFontSize,
|
||||
setSecondaryTitleSizeLocked,
|
||||
secondaryTitleTopMargin,
|
||||
setSecondaryTitleTopMargin,
|
||||
subtitleStyles,
|
||||
selectedSubtitleStyleId,
|
||||
setSelectedSubtitleStyleId,
|
||||
@@ -84,6 +97,8 @@ export function HomePage() {
|
||||
setTitleDisplayMode,
|
||||
outputAspectRatio,
|
||||
setOutputAspectRatio,
|
||||
lipsyncModelMode,
|
||||
setLipsyncModelMode,
|
||||
resolveAssetUrl,
|
||||
getFontFormat,
|
||||
buildTextShadow,
|
||||
@@ -117,6 +132,7 @@ export function HomePage() {
|
||||
startRecording,
|
||||
stopRecording,
|
||||
useRecording,
|
||||
discardRecording,
|
||||
formatRecordingTime,
|
||||
bgmList,
|
||||
bgmLoading,
|
||||
@@ -128,8 +144,6 @@ export function HomePage() {
|
||||
setSelectedBgmId,
|
||||
playingBgmId,
|
||||
toggleBgmPreview,
|
||||
bgmVolume,
|
||||
setBgmVolume,
|
||||
bgmListContainerRef,
|
||||
registerBgmItemRef,
|
||||
currentTask,
|
||||
@@ -155,6 +169,8 @@ export function HomePage() {
|
||||
selectAudio,
|
||||
speed,
|
||||
setSpeed,
|
||||
emotion,
|
||||
setEmotion,
|
||||
timelineSegments,
|
||||
reorderSegments,
|
||||
setSourceRange,
|
||||
@@ -162,6 +178,7 @@ export function HomePage() {
|
||||
setClipTrimmerOpen,
|
||||
clipTrimmerSegmentId,
|
||||
setClipTrimmerSegmentId,
|
||||
materialPosterUrl,
|
||||
} = useHomeController();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -170,7 +187,15 @@ export function HomePage() {
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
if ("scrollRestoration" in history) {
|
||||
history.scrollRestoration = "manual";
|
||||
}
|
||||
window.scrollTo({ top: 0, left: 0, behavior: "auto" });
|
||||
// 兜底:等所有恢复 effect + 异步数据加载 settle 后再次强制回顶部
|
||||
const timer = setTimeout(() => {
|
||||
window.scrollTo({ top: 0, left: 0, behavior: "auto" });
|
||||
}, 200);
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
const clipTrimmerSegment = useMemo(
|
||||
@@ -192,13 +217,12 @@ export function HomePage() {
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
{/* 左侧: 输入区域 */}
|
||||
<div className="space-y-6">
|
||||
{/* 1. 文案输入 */}
|
||||
{/* 一、文案提取与编辑 */}
|
||||
<ScriptEditor
|
||||
text={text}
|
||||
onChangeText={setText}
|
||||
onOpenExtractModal={() => setExtractModalOpen(true)}
|
||||
onGenerateMeta={handleGenerateMeta}
|
||||
isGeneratingMeta={isGeneratingMeta}
|
||||
onOpenRewriteModal={() => setRewriteModalOpen(true)}
|
||||
onTranslate={handleTranslate}
|
||||
isTranslating={isTranslating}
|
||||
hasOriginalText={originalText !== null}
|
||||
@@ -209,102 +233,80 @@ export function HomePage() {
|
||||
onDeleteScript={deleteSavedScript}
|
||||
/>
|
||||
|
||||
{/* 2. 标题和字幕设置 */}
|
||||
<TitleSubtitlePanel
|
||||
showStylePreview={showStylePreview}
|
||||
onTogglePreview={() => setShowStylePreview((prev) => !prev)}
|
||||
videoTitle={videoTitle}
|
||||
onTitleChange={titleInput.handleChange}
|
||||
onTitleCompositionStart={titleInput.handleCompositionStart}
|
||||
onTitleCompositionEnd={titleInput.handleCompositionEnd}
|
||||
titleStyles={titleStyles}
|
||||
selectedTitleStyleId={selectedTitleStyleId}
|
||||
onSelectTitleStyle={setSelectedTitleStyleId}
|
||||
titleFontSize={titleFontSize}
|
||||
onTitleFontSizeChange={(value) => {
|
||||
setTitleFontSize(value);
|
||||
setTitleSizeLocked(true);
|
||||
}}
|
||||
subtitleStyles={subtitleStyles}
|
||||
selectedSubtitleStyleId={selectedSubtitleStyleId}
|
||||
onSelectSubtitleStyle={setSelectedSubtitleStyleId}
|
||||
subtitleFontSize={subtitleFontSize}
|
||||
onSubtitleFontSizeChange={(value) => {
|
||||
setSubtitleFontSize(value);
|
||||
setSubtitleSizeLocked(true);
|
||||
}}
|
||||
titleTopMargin={titleTopMargin}
|
||||
onTitleTopMarginChange={setTitleTopMargin}
|
||||
subtitleBottomMargin={subtitleBottomMargin}
|
||||
onSubtitleBottomMarginChange={setSubtitleBottomMargin}
|
||||
titleDisplayMode={titleDisplayMode}
|
||||
onTitleDisplayModeChange={setTitleDisplayMode}
|
||||
resolveAssetUrl={resolveAssetUrl}
|
||||
getFontFormat={getFontFormat}
|
||||
buildTextShadow={buildTextShadow}
|
||||
previewBaseWidth={outputAspectRatio === "16:9" ? 1920 : 1080}
|
||||
previewBaseHeight={outputAspectRatio === "16:9" ? 1080 : 1920}
|
||||
/>
|
||||
{/* 二、配音 */}
|
||||
<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>
|
||||
<h3 className="text-sm font-medium text-gray-400 mb-3">配音方式</h3>
|
||||
<VoiceSelector
|
||||
embedded
|
||||
ttsMode={ttsMode}
|
||||
onSelectTtsMode={setTtsMode}
|
||||
voices={voices}
|
||||
voice={voice}
|
||||
onSelectVoice={setVoice}
|
||||
voiceCloneSlot={(
|
||||
<RefAudioPanel
|
||||
refAudios={refAudios}
|
||||
selectedRefAudio={selectedRefAudio}
|
||||
onSelectRefAudio={handleSelectRefAudio}
|
||||
isUploadingRef={isUploadingRef}
|
||||
uploadRefError={uploadRefError}
|
||||
onClearUploadRefError={() => setUploadRefError(null)}
|
||||
onUploadRefAudio={uploadRefAudio}
|
||||
onFetchRefAudios={fetchRefAudios}
|
||||
playingAudioId={playingAudioId}
|
||||
onTogglePlayPreview={togglePlayPreview}
|
||||
editingAudioId={editingAudioId}
|
||||
editName={editName}
|
||||
onEditNameChange={setEditName}
|
||||
onStartEditing={startEditing}
|
||||
onSaveEditing={saveEditing}
|
||||
onCancelEditing={cancelEditing}
|
||||
onDeleteRefAudio={deleteRefAudio}
|
||||
onRetranscribe={retranscribeRefAudio}
|
||||
retranscribingId={retranscribingId}
|
||||
recordedBlob={recordedBlob}
|
||||
isRecording={isRecording}
|
||||
recordingTime={recordingTime}
|
||||
onStartRecording={startRecording}
|
||||
onStopRecording={stopRecording}
|
||||
onUseRecording={useRecording}
|
||||
onDiscardRecording={discardRecording}
|
||||
formatRecordingTime={formatRecordingTime}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<div className="border-t border-white/10 my-4" />
|
||||
<GeneratedAudiosPanel
|
||||
embedded
|
||||
generatedAudios={generatedAudios}
|
||||
selectedAudioId={selectedAudioId}
|
||||
isGeneratingAudio={isGeneratingAudio}
|
||||
audioTask={audioTask}
|
||||
onGenerateAudio={handleGenerateAudio}
|
||||
onRefresh={() => fetchGeneratedAudios()}
|
||||
onSelectAudio={selectAudio}
|
||||
onDeleteAudio={deleteAudio}
|
||||
onRenameAudio={renameAudio}
|
||||
hasText={!!text.trim()}
|
||||
missingRefAudio={ttsMode === "voiceclone" && !selectedRefAudio}
|
||||
speed={speed}
|
||||
onSpeedChange={setSpeed}
|
||||
ttsMode={ttsMode}
|
||||
emotion={emotion}
|
||||
onEmotionChange={setEmotion}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 3. 配音方式选择 */}
|
||||
<VoiceSelector
|
||||
ttsMode={ttsMode}
|
||||
onSelectTtsMode={setTtsMode}
|
||||
voices={voices}
|
||||
voice={voice}
|
||||
onSelectVoice={setVoice}
|
||||
voiceCloneSlot={(
|
||||
<RefAudioPanel
|
||||
refAudios={refAudios}
|
||||
selectedRefAudio={selectedRefAudio}
|
||||
onSelectRefAudio={handleSelectRefAudio}
|
||||
isUploadingRef={isUploadingRef}
|
||||
uploadRefError={uploadRefError}
|
||||
onClearUploadRefError={() => setUploadRefError(null)}
|
||||
onUploadRefAudio={uploadRefAudio}
|
||||
onFetchRefAudios={fetchRefAudios}
|
||||
playingAudioId={playingAudioId}
|
||||
onTogglePlayPreview={togglePlayPreview}
|
||||
editingAudioId={editingAudioId}
|
||||
editName={editName}
|
||||
onEditNameChange={setEditName}
|
||||
onStartEditing={startEditing}
|
||||
onSaveEditing={saveEditing}
|
||||
onCancelEditing={cancelEditing}
|
||||
onDeleteRefAudio={deleteRefAudio}
|
||||
onRetranscribe={retranscribeRefAudio}
|
||||
retranscribingId={retranscribingId}
|
||||
recordedBlob={recordedBlob}
|
||||
isRecording={isRecording}
|
||||
recordingTime={recordingTime}
|
||||
onStartRecording={startRecording}
|
||||
onStopRecording={stopRecording}
|
||||
onUseRecording={useRecording}
|
||||
formatRecordingTime={formatRecordingTime}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* 4. 配音列表 */}
|
||||
<GeneratedAudiosPanel
|
||||
generatedAudios={generatedAudios}
|
||||
selectedAudioId={selectedAudioId}
|
||||
isGeneratingAudio={isGeneratingAudio}
|
||||
audioTask={audioTask}
|
||||
onGenerateAudio={handleGenerateAudio}
|
||||
onRefresh={() => fetchGeneratedAudios()}
|
||||
onSelectAudio={selectAudio}
|
||||
onDeleteAudio={deleteAudio}
|
||||
onRenameAudio={renameAudio}
|
||||
hasText={!!text.trim()}
|
||||
missingRefAudio={ttsMode === "voiceclone" && !selectedRefAudio}
|
||||
speed={speed}
|
||||
onSpeedChange={setSpeed}
|
||||
ttsMode={ttsMode}
|
||||
/>
|
||||
|
||||
{/* 5. 视频素材 */}
|
||||
<MaterialSelector
|
||||
{/* 三、素材编辑 */}
|
||||
<div className="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>
|
||||
<MaterialSelector
|
||||
embedded
|
||||
materials={materials}
|
||||
selectedMaterials={selectedMaterials}
|
||||
isFetching={isFetching}
|
||||
@@ -328,32 +330,87 @@ export function HomePage() {
|
||||
onClearUploadError={() => setUploadError(null)}
|
||||
registerMaterialRef={registerMaterialRef}
|
||||
/>
|
||||
|
||||
{/* 5.5 时间轴编辑器 — 未选配音/素材时模糊遮挡 */}
|
||||
<div className="relative">
|
||||
{(!selectedAudio || selectedMaterials.length === 0) && (
|
||||
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm rounded-2xl flex items-center justify-center z-10">
|
||||
<p className="text-gray-400">
|
||||
{!selectedAudio ? "请先生成并选中配音" : "请先选择素材"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<TimelineEditor
|
||||
audioDuration={selectedAudio?.duration_sec ?? 0}
|
||||
audioUrl={selectedAudio ? (resolveMediaUrl(selectedAudio.path) || "") : ""}
|
||||
segments={timelineSegments}
|
||||
materials={materials}
|
||||
outputAspectRatio={outputAspectRatio}
|
||||
onOutputAspectRatioChange={setOutputAspectRatio}
|
||||
onReorderSegment={reorderSegments}
|
||||
onClickSegment={(seg) => {
|
||||
setClipTrimmerSegmentId(seg.id);
|
||||
setClipTrimmerOpen(true);
|
||||
}}
|
||||
/>
|
||||
<div className="border-t border-white/10 my-4" />
|
||||
<div className="relative">
|
||||
{(!selectedAudio || selectedMaterials.length === 0) && (
|
||||
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm rounded-xl flex items-center justify-center z-10">
|
||||
<p className="text-gray-400">
|
||||
{!selectedAudio ? "请先生成并选中配音" : "请先选择素材"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<TimelineEditor
|
||||
embedded
|
||||
audioDuration={selectedAudio?.duration_sec ?? 0}
|
||||
audioUrl={selectedAudio ? (resolveMediaUrl(selectedAudio.path) || "") : ""}
|
||||
segments={timelineSegments}
|
||||
materials={materials}
|
||||
outputAspectRatio={outputAspectRatio}
|
||||
onOutputAspectRatioChange={setOutputAspectRatio}
|
||||
onReorderSegment={reorderSegments}
|
||||
onClickSegment={(seg) => {
|
||||
setClipTrimmerSegmentId(seg.id);
|
||||
setClipTrimmerOpen(true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 6. 背景音乐 */}
|
||||
{/* 四、标题与字幕 */}
|
||||
<TitleSubtitlePanel
|
||||
showStylePreview={showStylePreview}
|
||||
onTogglePreview={() => setShowStylePreview((prev) => !prev)}
|
||||
onGenerateMeta={handleGenerateMeta}
|
||||
isGeneratingMeta={isGeneratingMeta}
|
||||
canGenerateMeta={!!text.trim()}
|
||||
videoTitle={videoTitle}
|
||||
onTitleChange={titleInput.handleChange}
|
||||
onTitleCompositionStart={titleInput.handleCompositionStart}
|
||||
onTitleCompositionEnd={titleInput.handleCompositionEnd}
|
||||
videoSecondaryTitle={videoSecondaryTitle}
|
||||
onSecondaryTitleChange={secondaryTitleInput.handleChange}
|
||||
onSecondaryTitleCompositionStart={secondaryTitleInput.handleCompositionStart}
|
||||
onSecondaryTitleCompositionEnd={secondaryTitleInput.handleCompositionEnd}
|
||||
titleStyles={titleStyles}
|
||||
selectedTitleStyleId={selectedTitleStyleId}
|
||||
onSelectTitleStyle={setSelectedTitleStyleId}
|
||||
titleFontSize={titleFontSize}
|
||||
onTitleFontSizeChange={(value) => {
|
||||
setTitleFontSize(value);
|
||||
setTitleSizeLocked(true);
|
||||
}}
|
||||
selectedSecondaryTitleStyleId={selectedSecondaryTitleStyleId}
|
||||
onSelectSecondaryTitleStyle={setSelectedSecondaryTitleStyleId}
|
||||
secondaryTitleFontSize={secondaryTitleFontSize}
|
||||
onSecondaryTitleFontSizeChange={(value) => {
|
||||
setSecondaryTitleFontSize(value);
|
||||
setSecondaryTitleSizeLocked(true);
|
||||
}}
|
||||
secondaryTitleTopMargin={secondaryTitleTopMargin}
|
||||
onSecondaryTitleTopMarginChange={setSecondaryTitleTopMargin}
|
||||
subtitleStyles={subtitleStyles}
|
||||
selectedSubtitleStyleId={selectedSubtitleStyleId}
|
||||
onSelectSubtitleStyle={setSelectedSubtitleStyleId}
|
||||
subtitleFontSize={subtitleFontSize}
|
||||
onSubtitleFontSizeChange={(value) => {
|
||||
setSubtitleFontSize(value);
|
||||
setSubtitleSizeLocked(true);
|
||||
}}
|
||||
titleTopMargin={titleTopMargin}
|
||||
onTitleTopMarginChange={setTitleTopMargin}
|
||||
subtitleBottomMargin={subtitleBottomMargin}
|
||||
onSubtitleBottomMarginChange={setSubtitleBottomMargin}
|
||||
titleDisplayMode={titleDisplayMode}
|
||||
onTitleDisplayModeChange={setTitleDisplayMode}
|
||||
resolveAssetUrl={resolveAssetUrl}
|
||||
getFontFormat={getFontFormat}
|
||||
buildTextShadow={buildTextShadow}
|
||||
previewBaseWidth={outputAspectRatio === "16:9" ? 1920 : 1080}
|
||||
previewBaseHeight={outputAspectRatio === "16:9" ? 1080 : 1920}
|
||||
previewBackgroundUrl={materialPosterUrl}
|
||||
/>
|
||||
|
||||
{/* 背景音乐 (不编号) */}
|
||||
<BgmPanel
|
||||
bgmList={bgmList}
|
||||
bgmLoading={bgmLoading}
|
||||
@@ -365,39 +422,76 @@ export function HomePage() {
|
||||
onSelectBgm={setSelectedBgmId}
|
||||
playingBgmId={playingBgmId}
|
||||
onTogglePreview={toggleBgmPreview}
|
||||
bgmVolume={bgmVolume}
|
||||
onVolumeChange={setBgmVolume}
|
||||
bgmListContainerRef={bgmListContainerRef}
|
||||
registerBgmItemRef={registerBgmItemRef}
|
||||
/>
|
||||
|
||||
{/* 7. 生成按钮 */}
|
||||
{/* 生成按钮 (不编号) */}
|
||||
<GenerateActionBar
|
||||
isGenerating={isGenerating}
|
||||
progress={currentTask?.progress || 0}
|
||||
materialCount={selectedMaterials.length}
|
||||
disabled={isGenerating || selectedMaterials.length === 0 || !selectedAudio}
|
||||
modelMode={lipsyncModelMode}
|
||||
onModelModeChange={setLipsyncModelMode}
|
||||
onGenerate={handleGenerate}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 右侧: 预览区域 */}
|
||||
{/* 右侧: 作品区域 */}
|
||||
<div className="space-y-6">
|
||||
<PreviewPanel
|
||||
currentTask={currentTask}
|
||||
isGenerating={isGenerating}
|
||||
generatedVideo={generatedVideo}
|
||||
/>
|
||||
|
||||
<HistoryList
|
||||
generatedVideos={generatedVideos}
|
||||
selectedVideoId={selectedVideoId}
|
||||
onSelectVideo={handleSelectVideo}
|
||||
onDeleteVideo={deleteVideo}
|
||||
onRefresh={() => fetchGeneratedVideos()}
|
||||
registerVideoRef={registerVideoRef}
|
||||
formatDate={formatDate}
|
||||
/>
|
||||
{/* 生成进度(在作品卡片上方) */}
|
||||
{currentTask && isGenerating && (
|
||||
<div className="bg-white/5 rounded-2xl p-4 sm:p-6 border border-purple-500/30 backdrop-blur-sm">
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between text-sm text-purple-300 mb-1">
|
||||
<span>正在AI生成中...</span>
|
||||
<span>{currentTask.progress || 0}%</span>
|
||||
</div>
|
||||
<div className="h-3 bg-black/30 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-purple-500 to-pink-500 transition-all duration-300"
|
||||
style={{ width: `${currentTask.progress || 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* 六、作品 */}
|
||||
<div className="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>
|
||||
<div className="flex justify-between items-center mb-3">
|
||||
<h3 className="text-sm font-medium text-gray-400">作品列表</h3>
|
||||
<button
|
||||
onClick={() => fetchGeneratedVideos()}
|
||||
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>
|
||||
<HistoryList
|
||||
embedded
|
||||
generatedVideos={generatedVideos}
|
||||
selectedVideoId={selectedVideoId}
|
||||
onSelectVideo={handleSelectVideo}
|
||||
onDeleteVideo={deleteVideo}
|
||||
onRefresh={() => fetchGeneratedVideos()}
|
||||
registerVideoRef={registerVideoRef}
|
||||
formatDate={formatDate}
|
||||
/>
|
||||
<div className="border-t border-white/10 my-4" />
|
||||
<h3 className="text-sm font-medium text-gray-400 mb-3">作品预览</h3>
|
||||
<PreviewPanel
|
||||
embedded
|
||||
currentTask={null}
|
||||
isGenerating={false}
|
||||
generatedVideo={generatedVideo}
|
||||
generatedVideoId={selectedVideoId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
@@ -413,6 +507,13 @@ export function HomePage() {
|
||||
onApply={(nextText) => setText(nextText)}
|
||||
/>
|
||||
|
||||
<RewriteModal
|
||||
isOpen={rewriteModalOpen}
|
||||
onClose={() => setRewriteModalOpen(false)}
|
||||
originalText={text}
|
||||
onApply={(newText) => setText(newText)}
|
||||
/>
|
||||
|
||||
<ClipTrimmer
|
||||
isOpen={clipTrimmerOpen}
|
||||
segment={clipTrimmerSegment}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { type ChangeEvent, type MouseEvent } 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[];
|
||||
@@ -25,6 +26,7 @@ interface MaterialSelectorProps {
|
||||
onDeleteMaterial: (id: string) => void;
|
||||
onClearUploadError: () => void;
|
||||
registerMaterialRef: (id: string, element: HTMLDivElement | null) => void;
|
||||
embedded?: boolean;
|
||||
}
|
||||
|
||||
export function MaterialSelector({
|
||||
@@ -50,19 +52,65 @@ export function MaterialSelector({
|
||||
onDeleteMaterial,
|
||||
onClearUploadError,
|
||||
registerMaterialRef,
|
||||
embedded = false,
|
||||
}: MaterialSelectorProps) {
|
||||
const selectedSet = new Set(selectedMaterials);
|
||||
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]);
|
||||
|
||||
return (
|
||||
<div className="bg-white/5 rounded-2xl p-4 sm:p-6 border border-white/10 backdrop-blur-sm">
|
||||
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 = (
|
||||
<>
|
||||
<div className="flex justify-between items-center gap-2 mb-4">
|
||||
<h2 className="text-base sm:text-lg font-semibold text-white flex items-center gap-2 whitespace-nowrap">
|
||||
📹 视频素材
|
||||
<span className="ml-1 text-[11px] sm:text-xs text-gray-400/90 font-normal">
|
||||
(可多选,最多4个)
|
||||
</span>
|
||||
</h2>
|
||||
{!embedded ? (
|
||||
<h2 className="text-base sm:text-lg font-semibold text-white flex items-center gap-2 min-w-0">
|
||||
<span className="shrink-0">视频素材</span>
|
||||
<span className="text-[11px] sm:text-xs text-gray-400/90 font-normal truncate">
|
||||
(上传自拍视频,最多可选4个)
|
||||
</span>
|
||||
</h2>
|
||||
) : (
|
||||
<h3 className="text-sm font-medium text-gray-400 min-w-0">
|
||||
<span className="shrink-0">视频素材</span>
|
||||
<span className="ml-1 text-[11px] text-gray-400/90 font-normal hidden sm:inline">(上传自拍视频,最多可选4个)</span>
|
||||
</h3>
|
||||
)}
|
||||
<div className="flex gap-1.5">
|
||||
<input
|
||||
type="file"
|
||||
@@ -94,7 +142,7 @@ export function MaterialSelector({
|
||||
{isUploading && (
|
||||
<div className="mb-4 p-4 bg-purple-500/10 rounded-xl border border-purple-500/30">
|
||||
<div className="flex justify-between text-sm text-purple-300 mb-2">
|
||||
<span>📤 上传中...</span>
|
||||
<span>上传中...</span>
|
||||
<span>{uploadProgress}%</span>
|
||||
</div>
|
||||
<div className="h-2 bg-black/30 rounded-full overflow-hidden">
|
||||
@@ -108,7 +156,7 @@ export function MaterialSelector({
|
||||
|
||||
{uploadError && (
|
||||
<div className="mb-4 p-4 bg-red-500/20 text-red-200 rounded-xl text-sm flex justify-between items-center">
|
||||
<span>❌ {uploadError}</span>
|
||||
<span>{uploadError}</span>
|
||||
<button onClick={onClearUploadError} className="text-red-300 hover:text-white">
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
@@ -138,105 +186,159 @@ export function MaterialSelector({
|
||||
<div className="text-5xl mb-4">📁</div>
|
||||
<p>暂无视频素材</p>
|
||||
<p className="text-sm mt-2">
|
||||
点击上方「📤 上传视频」按钮添加视频素材
|
||||
点击上方「上传」按钮添加视频素材
|
||||
</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)} 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-0 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-0 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-0 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
if (embedded) return content;
|
||||
|
||||
return (
|
||||
<div className="bg-white/5 rounded-2xl p-4 sm:p-6 border border-white/10 backdrop-blur-sm">
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,18 +12,26 @@ interface PreviewPanelProps {
|
||||
currentTask: Task | null;
|
||||
isGenerating: boolean;
|
||||
generatedVideo: string | null;
|
||||
generatedVideoId?: string | null;
|
||||
embedded?: boolean;
|
||||
}
|
||||
|
||||
export function PreviewPanel({
|
||||
currentTask,
|
||||
isGenerating,
|
||||
generatedVideo,
|
||||
generatedVideoId = null,
|
||||
embedded = false,
|
||||
}: PreviewPanelProps) {
|
||||
return (
|
||||
const downloadHref = generatedVideoId
|
||||
? `/api/videos/generated/${encodeURIComponent(generatedVideoId)}/download`
|
||||
: generatedVideo;
|
||||
|
||||
const content = (
|
||||
<>
|
||||
{currentTask && isGenerating && (
|
||||
<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={embedded ? "mb-4" : "bg-white/5 rounded-2xl p-6 border border-white/10 backdrop-blur-sm"}>
|
||||
{!embedded && <h2 className="text-lg font-semibold text-white mb-4">生成进度</h2>}
|
||||
<div className="space-y-3">
|
||||
<div className="h-3 bg-black/30 rounded-full overflow-hidden">
|
||||
<div
|
||||
@@ -36,8 +44,8 @@ export function PreviewPanel({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-white/5 rounded-2xl p-6 border border-white/10 backdrop-blur-sm">
|
||||
<h2 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">🎥 作品预览</h2>
|
||||
<div className={embedded ? "" : "bg-white/5 rounded-2xl p-6 border border-white/10 backdrop-blur-sm"}>
|
||||
{!embedded && <h2 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">作品预览</h2>}
|
||||
<div className="aspect-video bg-black/50 rounded-xl overflow-hidden flex items-center justify-center">
|
||||
{generatedVideo ? (
|
||||
<video src={generatedVideo} controls preload="metadata" className="w-full h-full object-contain" />
|
||||
@@ -49,10 +57,10 @@ export function PreviewPanel({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{generatedVideo && (
|
||||
{generatedVideo && downloadHref && (
|
||||
<>
|
||||
<a
|
||||
href={generatedVideo}
|
||||
href={downloadHref}
|
||||
download
|
||||
className="mt-4 w-full py-3 rounded-xl bg-green-600 hover:bg-green-700 text-white font-medium flex items-center justify-center gap-2 transition-colors"
|
||||
>
|
||||
@@ -71,4 +79,6 @@ export function PreviewPanel({
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
<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>
|
||||
<span className="text-sm text-gray-300">📁 我的参考音频 <span className="text-xs text-gray-500 font-normal">(上传3-10秒语音样本)</span></span>
|
||||
<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,149 +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-0 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>
|
||||
|
||||
<p className="text-xs text-gray-500 mt-2 border-t border-white/10 pt-3">
|
||||
上传任意语音样本(3-10秒),系统将自动识别内容并克隆声音
|
||||
</p>
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
199
frontend/src/features/home/ui/RewriteModal.tsx
Normal file
199
frontend/src/features/home/ui/RewriteModal.tsx
Normal file
@@ -0,0 +1,199 @@
|
||||
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";
|
||||
|
||||
interface RewriteModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
originalText: string;
|
||||
onApply: (text: string) => void;
|
||||
}
|
||||
|
||||
export default function RewriteModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
originalText,
|
||||
onApply,
|
||||
}: RewriteModalProps) {
|
||||
const [customPrompt, setCustomPrompt] = useState(
|
||||
() => (typeof window !== "undefined" ? localStorage.getItem(CUSTOM_PROMPT_KEY) || "" : "")
|
||||
);
|
||||
const [rewrittenText, setRewrittenText] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Debounced save customPrompt to localStorage
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
useEffect(() => {
|
||||
debounceRef.current = setTimeout(() => {
|
||||
localStorage.setItem(CUSTOM_PROMPT_KEY, customPrompt);
|
||||
}, 300);
|
||||
return () => clearTimeout(debounceRef.current);
|
||||
}, [customPrompt]);
|
||||
|
||||
// Reset state when modal opens
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setRewrittenText("");
|
||||
setError(null);
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const handleRewrite = useCallback(async () => {
|
||||
if (!originalText.trim()) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const { data: res } = await api.post<
|
||||
ApiResponse<{ rewritten_text: string }>
|
||||
>("/api/ai/rewrite", {
|
||||
text: originalText,
|
||||
custom_prompt: customPrompt.trim() || null,
|
||||
});
|
||||
const payload = unwrap(res);
|
||||
setRewrittenText(payload.rewritten_text || "");
|
||||
} catch (err: unknown) {
|
||||
console.error("AI rewrite failed:", err);
|
||||
const axiosErr = err as {
|
||||
response?: { data?: { message?: string } };
|
||||
message?: string;
|
||||
};
|
||||
const msg =
|
||||
axiosErr.response?.data?.message || axiosErr.message || "改写失败,请重试";
|
||||
setError(msg);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [originalText, customPrompt]);
|
||||
|
||||
const handleApply = () => {
|
||||
onApply(rewrittenText);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleRetry = () => {
|
||||
setRewrittenText("");
|
||||
setError(null);
|
||||
};
|
||||
|
||||
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">
|
||||
自定义提示词 (可选)
|
||||
</label>
|
||||
<textarea
|
||||
value={customPrompt}
|
||||
onChange={(e) => setCustomPrompt(e.target.value)}
|
||||
placeholder="输入改写要求..."
|
||||
rows={3}
|
||||
className="w-full bg-black/20 border border-white/10 rounded-xl px-3 py-2 text-sm text-white placeholder-gray-500 focus:outline-none focus:border-purple-500 transition-colors resize-none"
|
||||
/>
|
||||
<p className="text-xs text-gray-500">留空则使用默认提示词</p>
|
||||
</div>
|
||||
|
||||
{/* Action button (before result) */}
|
||||
{!rewrittenText && (
|
||||
<button
|
||||
onClick={handleRewrite}
|
||||
disabled={isLoading || !originalText.trim()}
|
||||
className="w-full py-3 px-4 bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-500 hover:to-pink-500 disabled:opacity-50 disabled:cursor-not-allowed text-white rounded-xl transition-all font-medium shadow-lg flex items-center justify-center gap-2"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
改写中...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Sparkles className="w-5 h-5" />
|
||||
开始改写
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="bg-red-500/10 border border-red-500/30 rounded-xl p-4">
|
||||
<p className="text-red-400 text-sm">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Rewritten result */}
|
||||
{rewrittenText && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<h4 className="font-semibold text-purple-300 flex items-center gap-2">
|
||||
<Sparkles className="h-4 w-4" />
|
||||
AI 改写结果
|
||||
</h4>
|
||||
<button
|
||||
onClick={handleApply}
|
||||
className="text-xs bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-500 hover:to-pink-500 text-white px-3 py-1.5 rounded-lg transition-colors shadow-sm"
|
||||
>
|
||||
使用此结果
|
||||
</button>
|
||||
</div>
|
||||
<div className="bg-purple-900/10 border border-purple-500/20 rounded-xl p-4 max-h-60 overflow-y-auto hide-scrollbar">
|
||||
<p className="text-gray-200 text-sm leading-relaxed whitespace-pre-wrap">
|
||||
{rewrittenText}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<h4 className="font-semibold text-gray-400 flex items-center gap-2">
|
||||
📝 原文对比
|
||||
</h4>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-xs bg-white/10 hover:bg-white/20 text-white px-3 py-1.5 rounded-lg transition-colors"
|
||||
>
|
||||
保留原文
|
||||
</button>
|
||||
</div>
|
||||
<div className="bg-white/5 border border-white/10 rounded-xl p-4 max-h-40 overflow-y-auto hide-scrollbar">
|
||||
<p className="text-gray-400 text-sm leading-relaxed whitespace-pre-wrap">
|
||||
{originalText}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleRetry}
|
||||
className="w-full py-2.5 px-4 bg-white/10 hover:bg-white/20 text-white rounded-xl transition-colors"
|
||||
>
|
||||
重新改写
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</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" },
|
||||
@@ -18,8 +19,7 @@ interface ScriptEditorProps {
|
||||
text: string;
|
||||
onChangeText: (value: string) => void;
|
||||
onOpenExtractModal: () => void;
|
||||
onGenerateMeta: () => void;
|
||||
isGeneratingMeta: boolean;
|
||||
onOpenRewriteModal: () => void;
|
||||
onTranslate: (targetLang: string) => void;
|
||||
isTranslating: boolean;
|
||||
hasOriginalText: boolean;
|
||||
@@ -34,8 +34,7 @@ export function ScriptEditor({
|
||||
text,
|
||||
onChangeText,
|
||||
onOpenExtractModal,
|
||||
onGenerateMeta,
|
||||
isGeneratingMeta,
|
||||
onOpenRewriteModal,
|
||||
onTranslate,
|
||||
isTranslating,
|
||||
hasOriginalText,
|
||||
@@ -45,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;
|
||||
@@ -86,14 +92,14 @@ export function ScriptEditor({
|
||||
<div className="relative z-10 bg-white/5 rounded-2xl p-4 sm:p-6 border border-white/10 backdrop-blur-sm">
|
||||
<div className="mb-4 space-y-3">
|
||||
<h2 className="text-base sm:text-lg font-semibold text-white flex items-center gap-2">
|
||||
✍️ 文案提取与编辑
|
||||
一、文案提取与编辑
|
||||
</h2>
|
||||
<div className="flex gap-2 flex-wrap justify-end items-center">
|
||||
{/* 历史文案 */}
|
||||
<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" />
|
||||
历史文案
|
||||
@@ -123,7 +129,7 @@ export function ScriptEditor({
|
||||
e.stopPropagation();
|
||||
onDeleteScript(script.id);
|
||||
}}
|
||||
className="opacity-0 group-hover:opacity-100 p-1 text-gray-500 hover:text-red-400 transition-all shrink-0"
|
||||
className="opacity-40 group-hover:opacity-100 p-1 text-gray-500 hover:text-red-400 transition-all shrink-0"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</button>
|
||||
@@ -135,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" />
|
||||
文案提取助手
|
||||
@@ -144,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"
|
||||
}`}
|
||||
>
|
||||
@@ -188,49 +194,75 @@ export function ScriptEditor({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<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={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"
|
||||
}`}
|
||||
onClick={onOpenRewriteModal}
|
||||
disabled={!text.trim()}
|
||||
className={`${actionBtnBase} ${
|
||||
!text.trim()
|
||||
? "bg-gray-600 cursor-not-allowed text-gray-400"
|
||||
: "bg-purple-600 hover:bg-purple-700 text-white"
|
||||
}`}
|
||||
>
|
||||
{isGeneratingMeta ? (
|
||||
<>
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
生成中...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Sparkles className="h-3.5 w-3.5" />
|
||||
AI生成标题标签
|
||||
</>
|
||||
)}
|
||||
<Sparkles className="h-3.5 w-3.5" />
|
||||
AI智能改写
|
||||
</button>
|
||||
<button
|
||||
onClick={onSaveScript}
|
||||
disabled={!text.trim()}
|
||||
className={`${actionBtnBase} ${
|
||||
!text.trim()
|
||||
? "bg-gray-600 cursor-not-allowed text-gray-400"
|
||||
: "bg-amber-600 hover:bg-amber-700 text-white"
|
||||
}`}
|
||||
>
|
||||
<Save className="h-3.5 w-3.5" />
|
||||
保存文案
|
||||
</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="flex items-center justify-between mt-2 text-sm text-gray-400">
|
||||
<span>{text.length} 字</span>
|
||||
<button
|
||||
onClick={onSaveScript}
|
||||
disabled={!text.trim()}
|
||||
className={`px-2.5 py-1 text-xs rounded transition-all flex items-center gap-1 ${
|
||||
!text.trim()
|
||||
? "bg-gray-700 cursor-not-allowed text-gray-500"
|
||||
: "bg-amber-600/80 hover:bg-amber-600 text-white"
|
||||
}`}
|
||||
>
|
||||
<Save className="h-3 w-3" />
|
||||
保存文案
|
||||
</button>
|
||||
</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;
|
||||
@@ -18,15 +19,12 @@ export default function ScriptExtractionModal({
|
||||
const {
|
||||
isLoading,
|
||||
script,
|
||||
rewrittenScript,
|
||||
error,
|
||||
doRewrite,
|
||||
step,
|
||||
dragActive,
|
||||
selectedFile,
|
||||
activeTab,
|
||||
inputUrl,
|
||||
setDoRewrite,
|
||||
setActiveTab,
|
||||
setInputUrl,
|
||||
handleDrag,
|
||||
@@ -39,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;
|
||||
@@ -71,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">
|
||||
@@ -186,21 +175,6 @@ export default function ScriptExtractionModal({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Options */}
|
||||
<div className="flex items-center gap-3 bg-white/5 rounded-xl p-4 border border-white/10">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={doRewrite}
|
||||
onChange={(e) => setDoRewrite(e.target.checked)}
|
||||
className="w-4 h-4 rounded bg-white/10 border-white/20 text-purple-500 focus:ring-purple-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-300">
|
||||
AI 智能改写(去口语化)
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="bg-red-500/10 border border-red-500/30 rounded-xl p-4">
|
||||
@@ -244,9 +218,7 @@ export default function ScriptExtractionModal({
|
||||
<p className="text-sm text-gray-400 text-center max-w-sm px-4">
|
||||
{activeTab === "url" && "正在下载视频..."}
|
||||
<br />
|
||||
{doRewrite
|
||||
? "正在进行语音识别和 AI 智能改写..."
|
||||
: "正在进行语音识别..."}
|
||||
正在进行语音识别...
|
||||
<br />
|
||||
<span className="opacity-75">
|
||||
大文件可能需要几分钟,请不要关闭窗口
|
||||
@@ -257,60 +229,30 @@ export default function ScriptExtractionModal({
|
||||
|
||||
{step === "result" && (
|
||||
<div className="space-y-6">
|
||||
{rewrittenScript && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<h4 className="font-semibold text-purple-300 flex items-center gap-2">
|
||||
✨ AI 洗稿结果{" "}
|
||||
<span className="text-xs font-normal text-purple-400/70">
|
||||
(推荐)
|
||||
</span>
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<h4 className="font-semibold text-gray-300 flex items-center gap-2">
|
||||
🎙️ 识别结果
|
||||
</h4>
|
||||
<div className="flex items-center gap-2">
|
||||
{onApply && (
|
||||
<button
|
||||
onClick={() => handleApplyAndClose(rewrittenScript)}
|
||||
onClick={() => handleApplyAndClose(script)}
|
||||
className="text-xs bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-500 hover:to-pink-500 text-white px-3 py-1.5 rounded-lg transition-colors flex items-center gap-1 shadow-sm"
|
||||
>
|
||||
📥 填入
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => copyToClipboard(rewrittenScript)}
|
||||
className="text-xs bg-purple-600 hover:bg-purple-500 text-white px-3 py-1.5 rounded-lg transition-colors flex items-center gap-1"
|
||||
onClick={() => copyToClipboard(script)}
|
||||
className="text-xs bg-white/10 hover:bg-white/20 text-white px-3 py-1.5 rounded-lg transition-colors"
|
||||
>
|
||||
📋 复制内容
|
||||
复制
|
||||
</button>
|
||||
</div>
|
||||
<div className="bg-purple-900/10 border border-purple-500/20 rounded-xl p-4 max-h-60 overflow-y-auto custom-scrollbar">
|
||||
<p className="text-gray-200 text-sm leading-relaxed whitespace-pre-wrap">
|
||||
{rewrittenScript}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<h4 className="font-semibold text-gray-400 flex items-center gap-2">
|
||||
🎙️ 原始识别结果
|
||||
</h4>
|
||||
{onApply && (
|
||||
<button
|
||||
onClick={() => handleApplyAndClose(script)}
|
||||
className="text-xs bg-white/10 hover:bg-white/20 text-white px-3 py-1.5 rounded-lg transition-colors flex items-center gap-1"
|
||||
>
|
||||
📥 填入
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => copyToClipboard(script)}
|
||||
className="text-xs bg-white/10 hover:bg-white/20 text-white px-3 py-1.5 rounded-lg transition-colors"
|
||||
>
|
||||
复制
|
||||
</button>
|
||||
</div>
|
||||
<div className="bg-white/5 border border-white/10 rounded-xl p-4 max-h-40 overflow-y-auto custom-scrollbar">
|
||||
<p className="text-gray-400 text-sm leading-relaxed whitespace-pre-wrap">
|
||||
<div className="bg-white/5 border border-white/10 rounded-xl p-4 max-h-60 overflow-y-auto hide-scrollbar">
|
||||
<p className="text-gray-200 text-sm leading-relaxed whitespace-pre-wrap">
|
||||
{script}
|
||||
</p>
|
||||
</div>
|
||||
@@ -327,7 +269,6 @@ export default function ScriptExtractionModal({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppModal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useEffect, useRef, useCallback, useState } from "react";
|
||||
import { useEffect, useRef, useCallback, useState, useMemo } from "react";
|
||||
import WaveSurfer from "wavesurfer.js";
|
||||
import { ChevronDown } 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;
|
||||
audioUrl: string;
|
||||
@@ -13,14 +14,15 @@ interface TimelineEditorProps {
|
||||
onOutputAspectRatioChange: (ratio: "9:16" | "16:9") => void;
|
||||
onReorderSegment: (fromIdx: number, toIdx: number) => void;
|
||||
onClickSegment: (segment: TimelineSegment) => void;
|
||||
embedded?: boolean;
|
||||
}
|
||||
|
||||
function formatTime(sec: number): string {
|
||||
const m = Math.floor(sec / 60);
|
||||
const s = sec % 60;
|
||||
return `${String(m).padStart(2, "0")}:${s.toFixed(1).padStart(4, "0")}`;
|
||||
}
|
||||
|
||||
|
||||
function formatTime(sec: number): string {
|
||||
const m = Math.floor(sec / 60);
|
||||
const s = sec % 60;
|
||||
return `${String(m).padStart(2, "0")}:${s.toFixed(1).padStart(4, "0")}`;
|
||||
}
|
||||
|
||||
export function TimelineEditor({
|
||||
audioDuration,
|
||||
audioUrl,
|
||||
@@ -30,12 +32,13 @@ export function TimelineEditor({
|
||||
onOutputAspectRatioChange,
|
||||
onReorderSegment,
|
||||
onClickSegment,
|
||||
embedded = false,
|
||||
}: TimelineEditorProps) {
|
||||
const waveRef = useRef<HTMLDivElement>(null);
|
||||
const wsRef = useRef<WaveSurfer | null>(null);
|
||||
const [waveReady, setWaveReady] = useState(false);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
|
||||
const waveRef = useRef<HTMLDivElement>(null);
|
||||
const wsRef = useRef<WaveSurfer | null>(null);
|
||||
const [waveReady, setWaveReady] = useState(false);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
|
||||
// Refs for high-frequency DOM updates (avoid 60fps re-renders)
|
||||
const playheadRef = useRef<HTMLDivElement>(null);
|
||||
const timeRef = useRef<HTMLSpanElement>(null);
|
||||
@@ -44,14 +47,12 @@ export function TimelineEditor({
|
||||
useEffect(() => {
|
||||
audioDurationRef.current = audioDuration;
|
||||
}, [audioDuration]);
|
||||
|
||||
|
||||
// Drag-to-reorder state
|
||||
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" },
|
||||
@@ -59,66 +60,56 @@ 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
|
||||
// Create / recreate wavesurfer when audioUrl changes
|
||||
useEffect(() => {
|
||||
if (!waveRef.current || !audioUrl) return;
|
||||
|
||||
const playheadEl = playheadRef.current;
|
||||
const timeEl = timeRef.current;
|
||||
|
||||
// Destroy previous instance
|
||||
if (wsRef.current) {
|
||||
wsRef.current.destroy();
|
||||
wsRef.current = null;
|
||||
}
|
||||
|
||||
const ws = WaveSurfer.create({
|
||||
container: waveRef.current,
|
||||
height: 56,
|
||||
waveColor: "#6d28d9",
|
||||
progressColor: "#a855f7",
|
||||
barWidth: 2,
|
||||
barGap: 1,
|
||||
barRadius: 2,
|
||||
cursorWidth: 1,
|
||||
cursorColor: "#e879f9",
|
||||
interact: true,
|
||||
normalize: true,
|
||||
});
|
||||
|
||||
// Click waveform → seek + auto-play
|
||||
ws.on("interaction", () => ws.play());
|
||||
ws.on("play", () => setIsPlaying(true));
|
||||
ws.on("pause", () => setIsPlaying(false));
|
||||
ws.on("finish", () => {
|
||||
setIsPlaying(false);
|
||||
if (playheadRef.current) playheadRef.current.style.display = "none";
|
||||
});
|
||||
// High-frequency: update playhead + time via refs (no React re-render)
|
||||
ws.on("timeupdate", (time: number) => {
|
||||
const dur = audioDurationRef.current;
|
||||
if (playheadRef.current && dur > 0) {
|
||||
playheadRef.current.style.left = `${(time / dur) * 100}%`;
|
||||
playheadRef.current.style.display = "block";
|
||||
}
|
||||
if (timeRef.current) {
|
||||
timeRef.current.textContent = formatTime(time);
|
||||
}
|
||||
});
|
||||
|
||||
ws.load(audioUrl);
|
||||
wsRef.current = ws;
|
||||
|
||||
|
||||
// Destroy previous instance
|
||||
if (wsRef.current) {
|
||||
wsRef.current.destroy();
|
||||
wsRef.current = null;
|
||||
}
|
||||
|
||||
const ws = WaveSurfer.create({
|
||||
container: waveRef.current,
|
||||
height: 56,
|
||||
waveColor: "#6d28d9",
|
||||
progressColor: "#a855f7",
|
||||
barWidth: 2,
|
||||
barGap: 1,
|
||||
barRadius: 2,
|
||||
cursorWidth: 1,
|
||||
cursorColor: "#e879f9",
|
||||
interact: true,
|
||||
normalize: true,
|
||||
});
|
||||
|
||||
// Click waveform → seek + auto-play
|
||||
ws.on("interaction", () => ws.play());
|
||||
ws.on("play", () => setIsPlaying(true));
|
||||
ws.on("pause", () => setIsPlaying(false));
|
||||
ws.on("finish", () => {
|
||||
setIsPlaying(false);
|
||||
if (playheadRef.current) playheadRef.current.style.display = "none";
|
||||
});
|
||||
// High-frequency: update playhead + time via refs (no React re-render)
|
||||
ws.on("timeupdate", (time: number) => {
|
||||
const dur = audioDurationRef.current;
|
||||
if (playheadRef.current && dur > 0) {
|
||||
playheadRef.current.style.left = `${(time / dur) * 100}%`;
|
||||
playheadRef.current.style.display = "block";
|
||||
}
|
||||
if (timeRef.current) {
|
||||
timeRef.current.textContent = formatTime(time);
|
||||
}
|
||||
});
|
||||
|
||||
ws.load(audioUrl);
|
||||
wsRef.current = ws;
|
||||
|
||||
return () => {
|
||||
ws.destroy();
|
||||
wsRef.current = null;
|
||||
@@ -127,92 +118,108 @@ export function TimelineEditor({
|
||||
if (timeEl) timeEl.textContent = formatTime(0);
|
||||
};
|
||||
}, [audioUrl, waveReady]);
|
||||
|
||||
// Callback ref to detect when waveRef div mounts
|
||||
const waveCallbackRef = useCallback((node: HTMLDivElement | null) => {
|
||||
(waveRef as React.MutableRefObject<HTMLDivElement | null>).current = node;
|
||||
setWaveReady(!!node);
|
||||
}, []);
|
||||
|
||||
const handlePlayPause = useCallback(() => {
|
||||
wsRef.current?.playPause();
|
||||
}, []);
|
||||
|
||||
// Drag-to-reorder handlers
|
||||
const handleDragStart = useCallback((idx: number, e: React.DragEvent) => {
|
||||
setDragFromIdx(idx);
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
e.dataTransfer.setData("text/plain", String(idx));
|
||||
}, []);
|
||||
|
||||
const handleDragOver = useCallback((idx: number, e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "move";
|
||||
setDragOverIdx(idx);
|
||||
}, []);
|
||||
|
||||
const handleDragLeave = useCallback(() => {
|
||||
setDragOverIdx(null);
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback((toIdx: number, e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
const fromIdx = parseInt(e.dataTransfer.getData("text/plain"), 10);
|
||||
if (!isNaN(fromIdx) && fromIdx !== toIdx) {
|
||||
onReorderSegment(fromIdx, toIdx);
|
||||
}
|
||||
setDragFromIdx(null);
|
||||
setDragOverIdx(null);
|
||||
}, [onReorderSegment]);
|
||||
|
||||
const handleDragEnd = useCallback(() => {
|
||||
setDragFromIdx(null);
|
||||
setDragOverIdx(null);
|
||||
}, []);
|
||||
|
||||
// Filter visible vs overflow segments
|
||||
const visibleSegments = segments.filter((s) => s.start < audioDuration);
|
||||
const overflowSegments = segments.filter((s) => s.start >= audioDuration);
|
||||
const hasSegments = visibleSegments.length > 0;
|
||||
|
||||
return (
|
||||
<div className="bg-white/5 rounded-2xl p-4 sm:p-6 border border-white/10 backdrop-blur-sm">
|
||||
|
||||
// Callback ref to detect when waveRef div mounts
|
||||
const waveCallbackRef = useCallback((node: HTMLDivElement | null) => {
|
||||
(waveRef as React.MutableRefObject<HTMLDivElement | null>).current = node;
|
||||
setWaveReady(!!node);
|
||||
}, []);
|
||||
|
||||
const handlePlayPause = useCallback(() => {
|
||||
wsRef.current?.playPause();
|
||||
}, []);
|
||||
|
||||
// Drag-to-reorder handlers
|
||||
const handleDragStart = useCallback((idx: number, e: React.DragEvent) => {
|
||||
setDragFromIdx(idx);
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
e.dataTransfer.setData("text/plain", String(idx));
|
||||
}, []);
|
||||
|
||||
const handleDragOver = useCallback((idx: number, e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "move";
|
||||
setDragOverIdx(idx);
|
||||
}, []);
|
||||
|
||||
const handleDragLeave = useCallback(() => {
|
||||
setDragOverIdx(null);
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback((toIdx: number, e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
const fromIdx = parseInt(e.dataTransfer.getData("text/plain"), 10);
|
||||
if (!isNaN(fromIdx) && fromIdx !== toIdx) {
|
||||
onReorderSegment(fromIdx, toIdx);
|
||||
}
|
||||
setDragFromIdx(null);
|
||||
setDragOverIdx(null);
|
||||
}, [onReorderSegment]);
|
||||
|
||||
const handleDragEnd = useCallback(() => {
|
||||
setDragFromIdx(null);
|
||||
setDragOverIdx(null);
|
||||
}, []);
|
||||
|
||||
// Filter visible vs overflow segments
|
||||
const visibleSegments = useMemo(() => segments.filter((s) => s.start < audioDuration), [segments, audioDuration]);
|
||||
const overflowSegments = useMemo(() => segments.filter((s) => s.start >= audioDuration), [segments, audioDuration]);
|
||||
const hasSegments = visibleSegments.length > 0;
|
||||
|
||||
const content = (
|
||||
<>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-base sm:text-lg font-semibold text-white flex items-center gap-2">
|
||||
🎞️ 时间轴编辑
|
||||
</h2>
|
||||
{!embedded ? (
|
||||
<h2 className="text-base sm:text-lg font-semibold text-white flex items-center gap-2">
|
||||
时间轴编辑
|
||||
</h2>
|
||||
) : (
|
||||
<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 && (
|
||||
@@ -231,28 +238,28 @@ export function TimelineEditor({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Waveform — always rendered so ref stays mounted */}
|
||||
<div className="relative mb-1">
|
||||
<div ref={waveCallbackRef} className="rounded-lg overflow-hidden bg-black/20 cursor-pointer" style={{ minHeight: 56 }} />
|
||||
</div>
|
||||
|
||||
{/* Segment blocks or empty placeholder */}
|
||||
{hasSegments ? (
|
||||
<>
|
||||
<div className="relative h-14 flex select-none">
|
||||
{/* Playhead — syncs with audio playback */}
|
||||
<div
|
||||
ref={playheadRef}
|
||||
className="absolute top-0 h-full w-0.5 bg-fuchsia-400 z-10 pointer-events-none"
|
||||
style={{ display: "none", left: "0%" }}
|
||||
/>
|
||||
{visibleSegments.map((seg, i) => {
|
||||
const left = (seg.start / audioDuration) * 100;
|
||||
const width = ((seg.end - seg.start) / audioDuration) * 100;
|
||||
const segDur = seg.end - seg.start;
|
||||
const isDragTarget = dragOverIdx === i && dragFromIdx !== i;
|
||||
|
||||
|
||||
{/* Waveform — always rendered so ref stays mounted */}
|
||||
<div className="relative mb-1">
|
||||
<div ref={waveCallbackRef} className="rounded-lg overflow-hidden bg-black/20 cursor-pointer" style={{ minHeight: 56 }} />
|
||||
</div>
|
||||
|
||||
{/* Segment blocks or empty placeholder */}
|
||||
{hasSegments ? (
|
||||
<>
|
||||
<div className="relative h-14 flex select-none">
|
||||
{/* Playhead — syncs with audio playback */}
|
||||
<div
|
||||
ref={playheadRef}
|
||||
className="absolute top-0 h-full w-0.5 bg-fuchsia-400 z-10 pointer-events-none"
|
||||
style={{ display: "none", left: "0%" }}
|
||||
/>
|
||||
{visibleSegments.map((seg, i) => {
|
||||
const left = (seg.start / audioDuration) * 100;
|
||||
const width = ((seg.end - seg.start) / audioDuration) * 100;
|
||||
const segDur = seg.end - seg.start;
|
||||
const isDragTarget = dragOverIdx === i && dragFromIdx !== i;
|
||||
|
||||
// Compute loop portion for the last visible segment
|
||||
const isLastVisible = i === visibleSegments.length - 1;
|
||||
let loopPercent = 0;
|
||||
@@ -266,84 +273,93 @@ export function TimelineEditor({
|
||||
loopPercent = ((segDur - effDur) / segDur) * 100;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={seg.id} className="absolute top-0 h-full" style={{ left: `${left}%`, width: `${width}%` }}>
|
||||
<button
|
||||
draggable
|
||||
onDragStart={(e) => handleDragStart(i, e)}
|
||||
onDragOver={(e) => handleDragOver(i, e)}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={(e) => handleDrop(i, e)}
|
||||
onDragEnd={handleDragEnd}
|
||||
onClick={() => onClickSegment(seg)}
|
||||
className={`relative w-full h-full rounded-lg flex flex-col items-center justify-center overflow-hidden cursor-grab active:cursor-grabbing transition-all border ${
|
||||
isDragTarget
|
||||
? "ring-2 ring-purple-400 border-purple-400 scale-[1.02]"
|
||||
: dragFromIdx === i
|
||||
? "opacity-50 border-white/10"
|
||||
: "hover:opacity-90 border-white/10"
|
||||
}`}
|
||||
style={{ backgroundColor: seg.color + "33", borderColor: isDragTarget ? undefined : seg.color + "66" }}
|
||||
title={`拖拽可调换顺序 · 点击设置截取范围\n${seg.materialName}\n${segDur.toFixed(1)}s${loopPercent > 0 ? ` (含循环 ${(segDur * loopPercent / 100).toFixed(1)}s)` : ""}`}
|
||||
>
|
||||
<span className="text-[11px] text-white/90 truncate max-w-full px-1 leading-tight z-[1]">
|
||||
{seg.materialName}
|
||||
</span>
|
||||
<span className="text-[10px] text-white/60 leading-tight z-[1]">
|
||||
{segDur.toFixed(1)}s
|
||||
</span>
|
||||
{seg.sourceStart > 0 && (
|
||||
<span className="text-[9px] text-amber-400/80 leading-tight z-[1]">
|
||||
✂ {seg.sourceStart.toFixed(1)}s
|
||||
</span>
|
||||
)}
|
||||
{/* Loop fill stripe overlay */}
|
||||
{loopPercent > 0 && (
|
||||
<div
|
||||
className="absolute top-0 right-0 h-full pointer-events-none flex items-center justify-center"
|
||||
style={{
|
||||
width: `${loopPercent}%`,
|
||||
background: `repeating-linear-gradient(-45deg, transparent, transparent 3px, rgba(255,255,255,0.07) 3px, rgba(255,255,255,0.07) 6px)`,
|
||||
borderLeft: "1px dashed rgba(255,255,255,0.25)",
|
||||
}}
|
||||
>
|
||||
<span className="text-[9px] text-white/30">循环</span>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Overflow segments — shown as gray chips */}
|
||||
{overflowSegments.length > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-1.5 mt-1.5">
|
||||
<span className="text-[10px] text-gray-500">未使用:</span>
|
||||
{overflowSegments.map((seg) => (
|
||||
<span
|
||||
key={seg.id}
|
||||
className="text-[10px] text-gray-500 bg-white/5 border border-white/10 rounded px-1.5 py-0.5"
|
||||
>
|
||||
{seg.materialName}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-[10px] text-gray-500 mt-1.5">
|
||||
点击波形定位播放 · 拖拽色块调换顺序 · 点击色块设置截取范围
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="h-14 bg-white/5 rounded-lg" />
|
||||
<p className="text-[10px] text-gray-500 mt-1.5">
|
||||
选中配音和素材后可编辑时间轴
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={seg.id} className="absolute top-0 h-full" style={{ left: `${left}%`, width: `${width}%` }}>
|
||||
<button
|
||||
draggable
|
||||
onDragStart={(e) => handleDragStart(i, e)}
|
||||
onDragOver={(e) => handleDragOver(i, e)}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={(e) => handleDrop(i, e)}
|
||||
onDragEnd={handleDragEnd}
|
||||
onClick={() => onClickSegment(seg)}
|
||||
className={`relative w-full h-full rounded-lg flex flex-col items-center justify-center overflow-hidden cursor-grab active:cursor-grabbing transition-all border ${
|
||||
isDragTarget
|
||||
? "ring-2 ring-purple-400 border-purple-400 scale-[1.02]"
|
||||
: dragFromIdx === i
|
||||
? "opacity-50 border-white/10"
|
||||
: "hover:opacity-90 border-white/10"
|
||||
}`}
|
||||
style={{ backgroundColor: seg.color + "33", borderColor: isDragTarget ? undefined : seg.color + "66" }}
|
||||
title={`拖拽可调换顺序 · 点击设置截取范围\n${seg.materialName}\n${segDur.toFixed(1)}s${loopPercent > 0 ? ` (含循环 ${(segDur * loopPercent / 100).toFixed(1)}s)` : ""}`}
|
||||
>
|
||||
<GripVertical className="absolute top-0.5 left-0.5 h-3 w-3 text-white/30 z-[1]" />
|
||||
<span className="text-[11px] text-white/90 truncate max-w-full px-1 leading-tight z-[1]">
|
||||
{seg.materialName}
|
||||
</span>
|
||||
<span className="text-[10px] text-white/60 leading-tight z-[1]">
|
||||
{segDur.toFixed(1)}s
|
||||
</span>
|
||||
{seg.sourceStart > 0 && (
|
||||
<span className="text-[9px] text-amber-400/80 leading-tight z-[1]">
|
||||
✂ {seg.sourceStart.toFixed(1)}s
|
||||
</span>
|
||||
)}
|
||||
{/* Loop fill stripe overlay */}
|
||||
{loopPercent > 0 && (
|
||||
<div
|
||||
className="absolute top-0 right-0 h-full pointer-events-none flex items-center justify-center"
|
||||
style={{
|
||||
width: `${loopPercent}%`,
|
||||
background: `repeating-linear-gradient(-45deg, transparent, transparent 3px, rgba(255,255,255,0.07) 3px, rgba(255,255,255,0.07) 6px)`,
|
||||
borderLeft: "1px dashed rgba(255,255,255,0.25)",
|
||||
}}
|
||||
>
|
||||
<span className="text-[9px] text-white/30">循环</span>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Overflow segments — shown as gray chips */}
|
||||
{overflowSegments.length > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-1.5 mt-1.5">
|
||||
<span className="text-[10px] text-gray-500">未使用:</span>
|
||||
{overflowSegments.map((seg) => (
|
||||
<span
|
||||
key={seg.id}
|
||||
className="text-[10px] text-gray-500 bg-white/5 border border-white/10 rounded px-1.5 py-0.5"
|
||||
>
|
||||
{seg.materialName}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-[10px] text-gray-500 mt-1.5">
|
||||
点击波形定位播放 · 拖拽色块调换顺序 · 点击色块设置截取范围
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="h-14 bg-white/5 rounded-lg" />
|
||||
<p className="text-[10px] text-gray-500 mt-1.5">
|
||||
选中配音和素材后可编辑时间轴
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
if (embedded) return content;
|
||||
|
||||
return (
|
||||
<div className="bg-white/5 rounded-2xl p-4 sm:p-6 border border-white/10 backdrop-blur-sm">
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,15 +35,28 @@ interface TitleStyleOption {
|
||||
interface TitleSubtitlePanelProps {
|
||||
showStylePreview: boolean;
|
||||
onTogglePreview: () => void;
|
||||
onGenerateMeta: () => void;
|
||||
isGeneratingMeta: boolean;
|
||||
canGenerateMeta: boolean;
|
||||
videoTitle: string;
|
||||
onTitleChange: (value: string) => void;
|
||||
onTitleCompositionStart?: () => void;
|
||||
onTitleCompositionEnd?: (value: string) => void;
|
||||
videoSecondaryTitle: string;
|
||||
onSecondaryTitleChange: (value: string) => void;
|
||||
onSecondaryTitleCompositionStart?: () => void;
|
||||
onSecondaryTitleCompositionEnd?: (value: string) => void;
|
||||
titleStyles: TitleStyleOption[];
|
||||
selectedTitleStyleId: string;
|
||||
onSelectTitleStyle: (id: string) => void;
|
||||
titleFontSize: number;
|
||||
onTitleFontSizeChange: (value: number) => void;
|
||||
selectedSecondaryTitleStyleId: string;
|
||||
onSelectSecondaryTitleStyle: (id: string) => void;
|
||||
secondaryTitleFontSize: number;
|
||||
onSecondaryTitleFontSizeChange: (value: number) => void;
|
||||
secondaryTitleTopMargin: number;
|
||||
onSecondaryTitleTopMarginChange: (value: number) => void;
|
||||
subtitleStyles: SubtitleStyleOption[];
|
||||
selectedSubtitleStyleId: string;
|
||||
onSelectSubtitleStyle: (id: string) => void;
|
||||
@@ -59,20 +73,34 @@ interface TitleSubtitlePanelProps {
|
||||
buildTextShadow: (color: string, size: number) => string;
|
||||
previewBaseWidth?: number;
|
||||
previewBaseHeight?: number;
|
||||
previewBackgroundUrl?: string | null;
|
||||
}
|
||||
|
||||
export function TitleSubtitlePanel({
|
||||
showStylePreview,
|
||||
onTogglePreview,
|
||||
onGenerateMeta,
|
||||
isGeneratingMeta,
|
||||
canGenerateMeta,
|
||||
videoTitle,
|
||||
onTitleChange,
|
||||
onTitleCompositionStart,
|
||||
onTitleCompositionEnd,
|
||||
videoSecondaryTitle,
|
||||
onSecondaryTitleChange,
|
||||
onSecondaryTitleCompositionStart,
|
||||
onSecondaryTitleCompositionEnd,
|
||||
titleStyles,
|
||||
selectedTitleStyleId,
|
||||
onSelectTitleStyle,
|
||||
titleFontSize,
|
||||
onTitleFontSizeChange,
|
||||
selectedSecondaryTitleStyleId,
|
||||
onSelectSecondaryTitleStyle,
|
||||
secondaryTitleFontSize,
|
||||
onSecondaryTitleFontSizeChange,
|
||||
secondaryTitleTopMargin,
|
||||
onSecondaryTitleTopMarginChange,
|
||||
subtitleStyles,
|
||||
selectedSubtitleStyleId,
|
||||
onSelectSubtitleStyle,
|
||||
@@ -89,29 +117,115 @@ export function TitleSubtitlePanel({
|
||||
buildTextShadow,
|
||||
previewBaseWidth = 1080,
|
||||
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>
|
||||
<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 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={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"
|
||||
}`}
|
||||
>
|
||||
{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 && (
|
||||
<FloatingStylePreview
|
||||
onClose={onTogglePreview}
|
||||
videoTitle={videoTitle}
|
||||
videoSecondaryTitle={videoSecondaryTitle}
|
||||
titleStyles={titleStyles}
|
||||
selectedTitleStyleId={selectedTitleStyleId}
|
||||
titleFontSize={titleFontSize}
|
||||
selectedSecondaryTitleStyleId={selectedSecondaryTitleStyleId}
|
||||
secondaryTitleFontSize={secondaryTitleFontSize}
|
||||
secondaryTitleTopMargin={secondaryTitleTopMargin}
|
||||
subtitleStyles={subtitleStyles}
|
||||
selectedSubtitleStyleId={selectedSubtitleStyleId}
|
||||
subtitleFontSize={subtitleFontSize}
|
||||
@@ -123,24 +237,14 @@ export function TitleSubtitlePanel({
|
||||
buildTextShadow={buildTextShadow}
|
||||
previewBaseWidth={previewBaseWidth}
|
||||
previewBaseHeight={previewBaseHeight}
|
||||
previewBackgroundUrl={previewBackgroundUrl}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="mb-4">
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<label className="text-sm text-gray-300">片头标题(限制15个字)</label>
|
||||
<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="flex items-center justify-between mb-2">
|
||||
<label className="text-sm text-gray-300">片头标题</label>
|
||||
<span className={`text-xs ${videoTitle.length > 15 ? "text-red-400" : "text-gray-500"}`}>{videoTitle.length}/15</span>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
@@ -153,96 +257,195 @@ export function TitleSubtitlePanel({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<label className="text-sm text-gray-300">片头副标题</label>
|
||||
<span className={`text-xs ${videoSecondaryTitle.length > 20 ? "text-red-400" : "text-gray-500"}`}>{videoSecondaryTitle.length}/20</span>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={videoSecondaryTitle}
|
||||
onChange={(e) => onSecondaryTitleChange(e.target.value)}
|
||||
onCompositionStart={onSecondaryTitleCompositionStart}
|
||||
onCompositionEnd={(e) => onSecondaryTitleCompositionEnd?.(e.currentTarget.value)}
|
||||
placeholder="输入副标题,显示在主标题下方"
|
||||
className="w-full px-3 sm:px-4 py-2 text-sm sm:text-base bg-black/30 border border-white/10 rounded-xl text-white placeholder-gray-500 focus:outline-none focus:border-purple-500 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{titleStyles.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<label className="text-sm text-gray-300 mb-2 block">标题样式</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{titleStyles.map((style) => (
|
||||
<button
|
||||
key={style.id}
|
||||
onClick={() => onSelectTitleStyle(style.id)}
|
||||
className={`p-2 rounded-lg border transition-all text-left ${selectedTitleStyleId === style.id
|
||||
? "border-purple-500 bg-purple-500/20"
|
||||
: "border-white/10 bg-white/5 hover:border-white/30"
|
||||
}`}
|
||||
<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="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>
|
||||
)}
|
||||
>
|
||||
<div className="text-white text-sm truncate">{style.label}</div>
|
||||
<div className="text-xs text-gray-400 truncate">
|
||||
{style.font_family || style.font_file || ""}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
{({ 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="mt-3">
|
||||
<label className="text-xs text-gray-400 mb-2 block">标题字号: {titleFontSize}px</label>
|
||||
<input
|
||||
type="range"
|
||||
min="60"
|
||||
max="150"
|
||||
step="1"
|
||||
value={titleFontSize}
|
||||
onChange={(e) => onTitleFontSizeChange(parseInt(e.target.value, 10))}
|
||||
className="w-full accent-purple-500"
|
||||
/>
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="text-xs text-gray-400 shrink-0 w-20">字号 {titleFontSize}</label>
|
||||
<input type="range" min="60" max="150" step="1" value={titleFontSize} onChange={(e) => onTitleFontSizeChange(parseInt(e.target.value, 10))} className="flex-1 accent-purple-500" />
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<label className="text-xs text-gray-400 mb-2 block">标题位置: {titleTopMargin}px</label>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="300"
|
||||
step="1"
|
||||
value={titleTopMargin}
|
||||
onChange={(e) => onTitleTopMarginChange(parseInt(e.target.value, 10))}
|
||||
className="w-full accent-purple-500"
|
||||
/>
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="text-xs text-gray-400 shrink-0 w-20">位置 {titleTopMargin}</label>
|
||||
<input type="range" min="0" max="300" step="1" value={titleTopMargin} onChange={(e) => onTitleTopMarginChange(parseInt(e.target.value, 10))} className="flex-1 accent-purple-500" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{titleStyles.length > 0 && (
|
||||
<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="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>
|
||||
)}
|
||||
>
|
||||
{({ 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">
|
||||
<label className="text-xs text-gray-400 shrink-0 w-20">字号 {secondaryTitleFontSize}</label>
|
||||
<input type="range" min="30" max="100" step="1" value={secondaryTitleFontSize} onChange={(e) => onSecondaryTitleFontSizeChange(parseInt(e.target.value, 10))} className="flex-1 accent-purple-500" />
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="text-xs text-gray-400 shrink-0 w-20">间距 {secondaryTitleTopMargin}</label>
|
||||
<input type="range" min="0" max="100" step="1" value={secondaryTitleTopMargin} onChange={(e) => onSecondaryTitleTopMarginChange(parseInt(e.target.value, 10))} className="flex-1 accent-purple-500" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{subtitleStyles.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<label className="text-sm text-gray-300 mb-2 block">字幕样式</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{subtitleStyles.map((style) => (
|
||||
<button
|
||||
key={style.id}
|
||||
onClick={() => onSelectSubtitleStyle(style.id)}
|
||||
className={`p-2 rounded-lg border transition-all text-left ${selectedSubtitleStyleId === style.id
|
||||
? "border-purple-500 bg-purple-500/20"
|
||||
: "border-white/10 bg-white/5 hover:border-white/30"
|
||||
}`}
|
||||
<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="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>
|
||||
)}
|
||||
>
|
||||
<div className="text-white text-sm truncate">{style.label}</div>
|
||||
<div className="text-xs text-gray-400 truncate">
|
||||
{style.font_family || style.font_file || ""}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
{({ 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="mt-3">
|
||||
<label className="text-xs text-gray-400 mb-2 block">字幕字号: {subtitleFontSize}px</label>
|
||||
<input
|
||||
type="range"
|
||||
min="40"
|
||||
max="90"
|
||||
step="1"
|
||||
value={subtitleFontSize}
|
||||
onChange={(e) => onSubtitleFontSizeChange(parseInt(e.target.value, 10))}
|
||||
className="w-full accent-purple-500"
|
||||
/>
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="text-xs text-gray-400 shrink-0 w-20">字号 {subtitleFontSize}</label>
|
||||
<input type="range" min="40" max="90" step="1" value={subtitleFontSize} onChange={(e) => onSubtitleFontSizeChange(parseInt(e.target.value, 10))} className="flex-1 accent-purple-500" />
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<label className="text-xs text-gray-400 mb-2 block">字幕位置: {subtitleBottomMargin}px</label>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="300"
|
||||
step="1"
|
||||
value={subtitleBottomMargin}
|
||||
onChange={(e) => onSubtitleBottomMarginChange(parseInt(e.target.value, 10))}
|
||||
className="w-full accent-purple-500"
|
||||
/>
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="text-xs text-gray-400 shrink-0 w-20">位置 {subtitleBottomMargin}</label>
|
||||
<input type="range" min="0" max="300" step="1" value={subtitleBottomMargin} onChange={(e) => onSubtitleBottomMarginChange(parseInt(e.target.value, 10))} className="flex-1 accent-purple-500" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -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;
|
||||
@@ -13,6 +36,7 @@ interface VoiceSelectorProps {
|
||||
voice: string;
|
||||
onSelectVoice: (id: string) => void;
|
||||
voiceCloneSlot: ReactNode;
|
||||
embedded?: boolean;
|
||||
}
|
||||
|
||||
export function VoiceSelector({
|
||||
@@ -22,54 +46,225 @@ export function VoiceSelector({
|
||||
voice,
|
||||
onSelectVoice,
|
||||
voiceCloneSlot,
|
||||
embedded = false,
|
||||
}: VoiceSelectorProps) {
|
||||
return (
|
||||
<div className="bg-white/5 rounded-2xl p-6 border border-white/10 backdrop-blur-sm">
|
||||
<h2 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
|
||||
🎙️ 配音方式
|
||||
</h2>
|
||||
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">
|
||||
<button
|
||||
onClick={() => onSelectTtsMode("edgetts")}
|
||||
className={`flex-1 py-2 px-4 rounded-lg font-medium transition-all flex items-center justify-center gap-2 ${ttsMode === "edgetts"
|
||||
className={`flex-1 py-2 px-2 sm:px-4 rounded-lg text-sm sm:text-base font-medium transition-all flex items-center justify-center gap-1.5 sm:gap-2 ${ttsMode === "edgetts"
|
||||
? "bg-purple-600 text-white"
|
||||
: "bg-white/10 text-gray-300 hover:bg-white/20"
|
||||
}`}
|
||||
>
|
||||
<Volume2 className="h-4 w-4" />
|
||||
<Volume2 className="h-4 w-4 shrink-0" />
|
||||
选择声音
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onSelectTtsMode("voiceclone")}
|
||||
className={`flex-1 py-2 px-4 rounded-lg font-medium transition-all flex items-center justify-center gap-2 ${ttsMode === "voiceclone"
|
||||
className={`flex-1 py-2 px-2 sm:px-4 rounded-lg text-sm sm:text-base font-medium transition-all flex items-center justify-center gap-1.5 sm:gap-2 ${ttsMode === "voiceclone"
|
||||
? "bg-purple-600 text-white"
|
||||
: "bg-white/10 text-gray-300 hover:bg-white/20"
|
||||
}`}
|
||||
>
|
||||
<Mic className="h-4 w-4" />
|
||||
<Mic className="h-4 w-4 shrink-0" />
|
||||
克隆声音
|
||||
</button>
|
||||
</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>
|
||||
)}
|
||||
|
||||
{ttsMode === "voiceclone" && voiceCloneSlot}
|
||||
</>
|
||||
);
|
||||
|
||||
if (embedded) return content;
|
||||
|
||||
return (
|
||||
<div className="bg-white/5 rounded-2xl p-6 border border-white/10 backdrop-blur-sm">
|
||||
<h2 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
|
||||
🎙️ 配音方式
|
||||
</h2>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,9 +15,7 @@ interface UseScriptExtractionOptions {
|
||||
export const useScriptExtraction = ({ isOpen }: UseScriptExtractionOptions) => {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [script, setScript] = useState("");
|
||||
const [rewrittenScript, setRewrittenScript] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [doRewrite, setDoRewrite] = useState(true);
|
||||
const [step, setStep] = useState<ExtractionStep>("config");
|
||||
const [dragActive, setDragActive] = useState(false);
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
@@ -29,7 +27,6 @@ export const useScriptExtraction = ({ isOpen }: UseScriptExtractionOptions) => {
|
||||
if (isOpen) {
|
||||
setStep("config");
|
||||
setScript("");
|
||||
setRewrittenScript("");
|
||||
setError(null);
|
||||
setIsLoading(false);
|
||||
setSelectedFile(null);
|
||||
@@ -100,10 +97,10 @@ export const useScriptExtraction = ({ isOpen }: UseScriptExtractionOptions) => {
|
||||
} else if (activeTab === "url") {
|
||||
formData.append("url", inputUrl.trim());
|
||||
}
|
||||
formData.append("rewrite", doRewrite ? "true" : "false");
|
||||
formData.append("rewrite", "false");
|
||||
|
||||
const { data: res } = await api.post<
|
||||
ApiResponse<{ original_script: string; rewritten_script?: string }>
|
||||
ApiResponse<{ original_script: string }>
|
||||
>("/api/tools/extract-script", formData, {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
timeout: 180000, // 3 minutes timeout
|
||||
@@ -111,7 +108,6 @@ export const useScriptExtraction = ({ isOpen }: UseScriptExtractionOptions) => {
|
||||
|
||||
const payload = unwrap(res);
|
||||
setScript(payload.original_script);
|
||||
setRewrittenScript(payload.rewritten_script || "");
|
||||
setStep("result");
|
||||
} catch (err: unknown) {
|
||||
console.error(err);
|
||||
@@ -126,7 +122,7 @@ export const useScriptExtraction = ({ isOpen }: UseScriptExtractionOptions) => {
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [activeTab, selectedFile, inputUrl, doRewrite]);
|
||||
}, [activeTab, selectedFile, inputUrl]);
|
||||
|
||||
const copyToClipboard = useCallback((text: string) => {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
@@ -185,16 +181,13 @@ export const useScriptExtraction = ({ isOpen }: UseScriptExtractionOptions) => {
|
||||
// State
|
||||
isLoading,
|
||||
script,
|
||||
rewrittenScript,
|
||||
error,
|
||||
doRewrite,
|
||||
step,
|
||||
dragActive,
|
||||
selectedFile,
|
||||
activeTab,
|
||||
inputUrl,
|
||||
// Setters
|
||||
setDoRewrite,
|
||||
setActiveTab,
|
||||
setInputUrl,
|
||||
// Handlers
|
||||
|
||||
@@ -7,6 +7,7 @@ import { clampTitle } from "@/shared/lib/title";
|
||||
import { useTitleInput } from "@/shared/hooks/useTitleInput";
|
||||
import { useAuth } from "@/shared/contexts/AuthContext";
|
||||
import { useTask } from "@/shared/contexts/TaskContext";
|
||||
import { useCleanup } from "@/shared/contexts/CleanupContext";
|
||||
import { toast } from "sonner";
|
||||
import { usePublishPrefetch } from "@/shared/hooks/usePublishPrefetch";
|
||||
import {
|
||||
@@ -40,6 +41,7 @@ export const usePublishController = () => {
|
||||
|
||||
const { userId, isLoading: isAuthLoading } = useAuth();
|
||||
const { isGenerating } = useTask();
|
||||
const { triggerCleanup } = useCleanup();
|
||||
const prevIsGenerating = useRef(isGenerating);
|
||||
const { readPrefetch, updatePrefetch } = usePublishPrefetch();
|
||||
|
||||
@@ -83,6 +85,8 @@ export const usePublishController = () => {
|
||||
setVideos(nextVideos);
|
||||
if (nextVideos.length > 0 && autoSelectLatest) {
|
||||
setSelectedVideo(nextVideos[0].id);
|
||||
// 写入跨页面共享标记,让首页也能感知最新生成的视频
|
||||
localStorage.setItem(`vigent_${getStorageKey()}_latestGeneratedVideoId`, nextVideos[0].id);
|
||||
}
|
||||
updatePrefetch({ videos: nextVideos });
|
||||
} catch (error) {
|
||||
@@ -109,16 +113,23 @@ export const usePublishController = () => {
|
||||
|
||||
// ---- 视频选择恢复(唯一一个 effect,条件极简) ----
|
||||
// 等 auth 完成 + videos 有数据 → 恢复一次,之后再也不跑
|
||||
// 优先检查跨页面共享标记(最新生成的视频),其次恢复上次选择
|
||||
useEffect(() => {
|
||||
if (isAuthLoading || videos.length === 0 || videoRestoredRef.current) return;
|
||||
videoRestoredRef.current = true;
|
||||
|
||||
const key = getStorageKey();
|
||||
const saved = localStorage.getItem(`vigent_${key}_publish_selected_video`);
|
||||
if (saved && videos.some(v => v.id === saved)) {
|
||||
setSelectedVideo(saved);
|
||||
const latestId = localStorage.getItem(`vigent_${key}_latestGeneratedVideoId`);
|
||||
if (latestId && videos.some(v => v.id === latestId)) {
|
||||
setSelectedVideo(latestId);
|
||||
localStorage.removeItem(`vigent_${key}_latestGeneratedVideoId`);
|
||||
} else {
|
||||
setSelectedVideo(videos[0].id);
|
||||
const saved = localStorage.getItem(`vigent_${key}_publish_selected_video`);
|
||||
if (saved && videos.some(v => v.id === saved)) {
|
||||
setSelectedVideo(saved);
|
||||
} else {
|
||||
setSelectedVideo(videos[0].id);
|
||||
}
|
||||
}
|
||||
}, [isAuthLoading, videos, getStorageKey]);
|
||||
|
||||
@@ -174,6 +185,23 @@ export const usePublishController = () => {
|
||||
window.scrollTo({ top: 0, left: 0, behavior: "auto" });
|
||||
}, []);
|
||||
|
||||
// ---- 工作区清理事件(清理后同步重置当前页输入态) ----
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
const handleWorkspaceCleared = (event: Event) => {
|
||||
const detail = (event as CustomEvent<{ userId?: string }>).detail;
|
||||
if (!detail?.userId || detail.userId !== userId) return;
|
||||
|
||||
setTitle("");
|
||||
setTags("");
|
||||
setPublishResults([]);
|
||||
};
|
||||
|
||||
window.addEventListener("vigent:workspace-cleared", handleWorkspaceCleared);
|
||||
return () => window.removeEventListener("vigent:workspace-cleared", handleWorkspaceCleared);
|
||||
}, [userId]);
|
||||
|
||||
// ---- 发布防误操作 ----
|
||||
useEffect(() => {
|
||||
if (!isPublishing) return;
|
||||
@@ -222,6 +250,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));
|
||||
@@ -243,7 +294,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: "",
|
||||
@@ -251,19 +303,31 @@ 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);
|
||||
const allSuccess = results.length > 0 && results.every(r => r.success);
|
||||
if (allSuccess) {
|
||||
triggerCleanup(results, video.id);
|
||||
} else {
|
||||
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 - 统一样式 */}
|
||||
@@ -135,7 +160,7 @@ export function PublishPage() {
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white/5 rounded-2xl p-6 border border-white/10 backdrop-blur-sm">
|
||||
<h2 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
|
||||
👤 平台账号
|
||||
七、平台账号
|
||||
</h2>
|
||||
|
||||
{isAccountsLoading ? (
|
||||
@@ -157,62 +182,60 @@ export function PublishPage() {
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2 sm:space-y-3">
|
||||
{accounts.map((account) => (
|
||||
<div
|
||||
key={account.platform}
|
||||
className="flex items-center justify-between p-4 bg-black/30 rounded-xl"
|
||||
className="flex items-center gap-3 px-3 py-2.5 sm:px-4 sm:py-3.5 bg-black/30 rounded-xl"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{platformIcons[account.platform] ? (
|
||||
<Image
|
||||
src={platformIcons[account.platform].src}
|
||||
alt={platformIcons[account.platform].alt}
|
||||
width={28}
|
||||
height={28}
|
||||
className="h-7 w-7"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-2xl">🌐</span>
|
||||
)}
|
||||
<div>
|
||||
<div className="text-white font-medium">
|
||||
{account.name}
|
||||
</div>
|
||||
<div
|
||||
className={`text-sm ${account.logged_in
|
||||
? "text-green-400"
|
||||
: "text-gray-500"
|
||||
}`}
|
||||
>
|
||||
{account.logged_in ? "✓ 已登录" : "未登录"}
|
||||
</div>
|
||||
{platformIcons[account.platform] ? (
|
||||
<Image
|
||||
src={platformIcons[account.platform].src}
|
||||
alt={platformIcons[account.platform].alt}
|
||||
width={28}
|
||||
height={28}
|
||||
className="h-6 w-6 sm:h-7 sm:w-7 shrink-0"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-xl sm:text-2xl">🌐</span>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm sm:text-base text-white font-medium leading-tight">
|
||||
{account.name}
|
||||
</div>
|
||||
<div
|
||||
className={`text-xs sm:text-sm leading-tight ${account.logged_in
|
||||
? "text-green-400"
|
||||
: "text-gray-500"
|
||||
}`}
|
||||
>
|
||||
{account.logged_in ? "✓ 已登录" : "未登录"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex items-center gap-1.5 sm:gap-2 shrink-0">
|
||||
{account.logged_in ? (
|
||||
<>
|
||||
<button
|
||||
onClick={() => handleLogin(account.platform)}
|
||||
className="px-3 py-1 bg-white/10 hover:bg-white/20 text-white text-sm rounded-lg transition-colors flex items-center gap-1"
|
||||
className="px-2 py-1 sm:px-3 sm:py-1.5 bg-white/10 hover:bg-white/20 text-white text-xs sm:text-sm rounded-md sm:rounded-lg transition-colors flex items-center gap-1"
|
||||
>
|
||||
<RotateCcw className="h-3.5 w-3.5" />
|
||||
<RotateCcw className="h-3 w-3 sm:h-3.5 sm:w-3.5" />
|
||||
重新登录
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleLogout(account.platform)}
|
||||
className="px-3 py-1 bg-red-500/80 hover:bg-red-600 text-white text-sm rounded-lg transition-colors flex items-center gap-1"
|
||||
className="px-2 py-1 sm:px-3 sm:py-1.5 bg-red-500/80 hover:bg-red-600 text-white text-xs sm:text-sm rounded-md sm:rounded-lg transition-colors flex items-center gap-1"
|
||||
>
|
||||
<LogOut className="h-3.5 w-3.5" />
|
||||
<LogOut className="h-3 w-3 sm:h-3.5 sm:w-3.5" />
|
||||
注销
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => handleLogin(account.platform)}
|
||||
className="px-3 py-1 bg-purple-500/80 hover:bg-purple-600 text-white text-sm rounded-lg transition-colors flex items-center gap-1"
|
||||
className="px-2 py-1 sm:px-3 sm:py-1.5 bg-purple-500/80 hover:bg-purple-600 text-white text-xs sm:text-sm rounded-md sm:rounded-lg transition-colors flex items-center gap-1"
|
||||
>
|
||||
<QrCode className="h-3.5 w-3.5" />
|
||||
<QrCode className="h-3 w-3 sm:h-3.5 sm:w-3.5" />
|
||||
登录
|
||||
</button>
|
||||
)}
|
||||
@@ -228,82 +251,118 @@ export function PublishPage() {
|
||||
<div className="space-y-6">
|
||||
{/* 选择视频 */}
|
||||
<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>
|
||||
)}
|
||||
<h2 className="text-lg font-semibold text-white mb-4">八、选择发布作品</h2>
|
||||
<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>
|
||||
|
||||
{/* 填写信息 */}
|
||||
<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>
|
||||
<h2 className="text-lg font-semibold text-white mb-4">九、发布信息</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
@@ -337,7 +396,7 @@ 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>
|
||||
<h2 className="text-lg font-semibold text-white mb-4">十、选择发布平台</h2>
|
||||
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{accounts
|
||||
|
||||
@@ -12,7 +12,7 @@ const API_BASE = typeof window === 'undefined'
|
||||
// 防止重复跳转
|
||||
let isRedirecting = false;
|
||||
|
||||
const PUBLIC_PATHS = new Set(['/login', '/register']);
|
||||
const PUBLIC_PATHS = new Set(['/login', '/register', '/pay']);
|
||||
|
||||
// 创建 axios 实例
|
||||
const api = axios.create({
|
||||
|
||||
@@ -11,6 +11,7 @@ interface AuthContextType {
|
||||
user: User | null;
|
||||
isLoading: boolean;
|
||||
isAuthenticated: boolean;
|
||||
setUser: (user: User | null) => void;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType>({
|
||||
@@ -18,6 +19,7 @@ const AuthContext = createContext<AuthContextType>({
|
||||
user: null,
|
||||
isLoading: true,
|
||||
isAuthenticated: false,
|
||||
setUser: () => {},
|
||||
});
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
@@ -63,7 +65,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
userId: user?.id || null,
|
||||
user,
|
||||
isLoading,
|
||||
isAuthenticated: !!user
|
||||
isAuthenticated: !!user,
|
||||
setUser,
|
||||
}}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user