Compare commits

..

15 Commits

Author SHA1 Message Date
Kevin Wong
23ff4ff86e 更新 2026-03-04 14:07:54 +08:00
Kevin Wong
091f78174e 更新 2026-03-03 15:16:38 +08:00
Kevin Wong
190fc2e590 更新 2026-03-03 12:23:49 +08:00
Kevin Wong
48bc78fe38 更新 2026-03-02 16:35:16 +08:00
Kevin Wong
abf005f225 更新 2026-02-28 17:49:32 +08:00
Kevin Wong
9de2cb40b4 更新 2026-02-28 14:44:51 +08:00
Kevin Wong
29c67f629d 更新 2026-02-28 09:16:41 +08:00
Kevin Wong
0e3502c6f0 更新 2026-02-27 16:11:34 +08:00
Kevin Wong
a1604979f0 更新 2026-02-26 11:13:03 +08:00
Kevin Wong
08221e48de 更新 2026-02-26 10:49:22 +08:00
Kevin Wong
42b5cc0c02 更新 2026-02-26 10:14:41 +08:00
Kevin Wong
1717635bfd 更新 2026-02-25 17:51:58 +08:00
Kevin Wong
0a5a17402c 更新 2026-02-24 16:55:29 +08:00
Kevin Wong
bc0fe9326a 更新 2026-02-11 17:48:38 +08:00
Kevin Wong
035ee29d72 更新 2026-02-11 14:33:05 +08:00
201 changed files with 126073 additions and 3020 deletions

1
.gitignore vendored
View File

@@ -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
View 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()` 中修复。

View File

@@ -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
@@ -74,6 +81,28 @@ backend/
- 错误通过 `HTTPException` 抛出,统一由全局异常处理返回 `{success:false, message, code}`
- 不再使用 `detail` 作为前端错误文案(前端已改为读 `message`)。
### `/api/videos/generate` 参数契约(关键约定)
- `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_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. 认证与权限
@@ -96,6 +125,8 @@ backend/
- 所有文件上传/下载/删除/移动通过 `services/storage.py`
- 需要重命名时使用 `move_file`,避免直接读写 Storage。
- `delete_file` 必须向上抛出异常,不允许静默吞错(避免清理接口出现“假成功”)。
- `list_files` 默认容错返回空列表;清理等强一致场景应使用 `strict=True`
### Cookie 存储(用户隔离)
@@ -143,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`
@@ -157,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`
---

View File

@@ -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,13 +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_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
@@ -146,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 改写/多语言文案与转录不一致问题。
## 📦 资源库与静态资源
@@ -176,7 +223,7 @@ pip install -r requirements.txt
### 3. 环境变量配置
复制 `.env.example``.env` 并配置必要的 Key
当前仓库使用 `backend/.env` 作为运行配置基准;请按你的环境替换敏感值并核对以下关键项(生产环境请勿提交真实密钥)
```ini
# Supabase
@@ -188,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. 启动服务
@@ -199,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

View File

@@ -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 音频文件

View File

@@ -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 共享 GPU0fp16 推理约需 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` | 支付宝支付集成 |
### 前端关键依赖

View File

@@ -47,6 +47,16 @@
- 开启可换行:`white-space: normal` + `word-break` + `overflow-wrap`
- 描边、字距、上下边距同步按比例缩放。
### 2.3 片头标题显示模式(短暂/常驻)
- 在“标题与字幕”面板的“片头标题”行尾新增下拉,支持:`短暂显示` / `常驻显示`
- 默认模式为 `短暂显示`,短暂模式默认时长为 4 秒。
- 用户选择会持久化到 localStorage刷新后保持上次配置。
- 生成请求新增 `title_display_mode`,短暂模式透传 `title_duration=4.0`
- Remotion 端到端支持该参数:
- `short`:标题在设定时长后淡出并结束渲染;
- `persistent`:标题全程常驻(保留淡入动画,不执行淡出)。
---
## 🎥 方向归一化 + 多素材拼接稳定性 — 第三阶段 (Day 24)
@@ -139,8 +149,9 @@
| `backend/app/core/deps.py` | `get_current_user` / `get_current_user_optional` 接入到期失效检查 |
| `backend/app/modules/auth/router.py` | 登录时到期停用 + `/api/auth/me` 统一鉴权依赖 |
| `backend/app/modules/videos/schemas.py` | `CustomAssignment` 新增 `source_end`;保留 `output_aspect_ratio` |
| `backend/app/modules/videos/workflow.py` | 多素材/单素材透传 `source_end`;多素材 prepare/concat 统一 25fps |
| `backend/app/modules/videos/workflow.py` | 多素材/单素材透传 `source_end`;多素材 prepare/concat 统一 25fps;标题显示模式参数透传 Remotion |
| `backend/app/services/video_service.py` | 旋转元数据解析与方向归一化;`prepare_segment` 支持 `source_end/target_fps`concat 强制 CFR + `+genpts` |
| `backend/app/services/remotion_service.py` | render 支持 `title_display_mode/title_duration` 并传递到 render.ts |
### 前端修改
@@ -149,20 +160,26 @@
| `frontend/src/features/home/model/useTimelineEditor.ts` | `CustomAssignment` 新增 `source_end`;修复 sourceStart 开放终点时长计算 |
| `frontend/src/features/home/model/useHomeController.ts` | 多素材以可见 assignments 为准发送;单素材截取触发条件补齐 |
| `frontend/src/features/home/ui/TimelineEditor.tsx` | 画面比例下拉;循环比例按截取后有效时长计算 |
| `frontend/src/features/home/model/useHomePersistence.ts` | `outputAspectRatio` 持久化 |
| `frontend/src/features/home/model/useHomePersistence.ts` | `outputAspectRatio``titleDisplayMode` 持久化 |
| `frontend/src/features/home/ui/HomePage.tsx` | 页面进入滚动到顶部ClipTrimmer/Timeline 交互保持一致 |
| `frontend/src/features/home/ui/FloatingStylePreview.tsx` | 标题/字幕样式预览与成片渲染策略对齐 |
| `frontend/src/features/home/ui/TitleSubtitlePanel.tsx` | 标题行新增“短暂显示/常驻显示”下拉 |
### Remotion 修改
| 文件 | 变更 |
|------|------|
| `remotion/src/components/Title.tsx` | 标题响应式缩放与自动换行,优化竖屏窄画布适配 |
| `remotion/src/components/Title.tsx` | 标题响应式缩放与自动换行;新增短暂/常驻显示模式控制 |
| `remotion/src/components/Subtitles.tsx` | 字幕响应式缩放与自动换行,减少预览/成片差异 |
| `remotion/src/Video.tsx` | 新增 `titleDisplayMode` 透传到标题组件 |
| `remotion/src/Root.tsx` | 默认 props 增加 `titleDisplayMode='short'``titleDuration=4` |
| `remotion/render.ts` | CLI 参数新增 `--titleDisplayMode`inputProps 增加 `titleDisplayMode` |
---
## 验证记录
- 后端语法检查:`python -m py_compile backend/app/modules/videos/schemas.py backend/app/modules/videos/workflow.py backend/app/services/video_service.py`
- 后端语法检查:`python -m py_compile backend/app/modules/videos/schemas.py backend/app/modules/videos/workflow.py backend/app/services/video_service.py backend/app/services/remotion_service.py`
- 前端类型检查:`npx tsc --noEmit`
- 前端 ESLint`npx eslint src/features/home/model/useHomeController.ts src/features/home/model/useHomePersistence.ts src/features/home/ui/HomePage.tsx src/features/home/ui/TitleSubtitlePanel.tsx`
- Remotion 渲染脚本构建:`npm run build:render`

254
Docs/DevLogs/Day25.md Normal file
View 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` 的初始化函数在 SSRNode.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
View 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` ref1 秒内禁止所有列表自动滚动)替代单次 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、MaterialSelector2处、ScriptExtractionModal2处
- 滚动功能不受影响,仅视觉上不显示滚动条
### 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自渲染子标题+操作按钮useMemodisabled 守卫,操作按钮可见度,标题溢出修复 |
| `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
View 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 → ~50s4 素材 ~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
View 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` | **新建** — 视频帧截取 hookcrossOrigin + canvas 缩放 |
| `frontend/.../useHomeController.ts` | 新增 useMemo 计算素材 URL调用帧截取 hookshowStylePreview 门控 |
| `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 降到 819s2.2x但合成阶段Phase 6仍占 462.2s (56.4%),是最大单一瓶颈。本次优化两个方向:纯 numpy blending 替代 PIL 转换、FFmpeg pipe + NVENC GPU 硬编码替代双重编码。
### 1. 纯 numpy blending 替代 PILblending.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_fastPhase 7: -c:v copy`run_ffmpeg` 去掉 shell=True |

283
Docs/DevLogs/Day29.md Normal file
View 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
View 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` 检测 FPS25fps 时跳过重编码 |
| `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
- 合成 fallbackresize 失败、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` 检测 FPS25fps 时跳过重编码 |
| `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` | 线程池化;同分辨率跳过 scalecompose 跳过;片段校验;模型选择透传 |
| `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
View 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
View 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"}`

View File

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

View File

@@ -1,5 +1,11 @@
# 前端开发规范
## 文档定位
- 本文档只定义前端开发规范与约束结构、交互、持久化、接口调用、Checklist
- 功能说明与启动方式请查看 `Docs/FRONTEND_README.md`
- 历史变更请记录在 `Docs/DevLogs/``Docs/TASK_COMPLETE.md`,不要写入本规范文档。
## 目录结构
采用轻量 FSDFeature-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/ # 全局 ContextAuth、Task
│ └── contexts/ # 全局 ContextAuth、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 请求规范
@@ -256,6 +342,38 @@ import { formatDate } from '@/shared/lib/media';
## ⚡️ 体验优化规范
### 刷新回顶部(统一体验)
- 长页面(如首页/发布页)在首次挂载时统一回到顶部。
- **必须**在页面级 `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);
}, []);
// 列表滚动 effectBGM/素材/视频等)
useEffect(() => {
if (!selectedId || !scrollEffectsEnabled.current) return;
target?.scrollIntoView({ block: "nearest", behavior: "smooth" });
}, [selectedId, list]);
```
### 路由预取
- 首页进入发布管理时使用 `router.prefetch("/publish")`
@@ -286,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`:全局 ContextAuthContext / TaskContext
- `shared/contexts`:全局 ContextAuthContext / TaskContext / CleanupContext
- `components/`遗留通用组件VideoPreviewModal
## 类型定义规范
@@ -305,10 +424,15 @@ 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)
### 历史文案(独立持久化)
@@ -333,6 +457,7 @@ import { formatDate } from '@/shared/lib/media';
- 片头标题与发布信息标题统一限制 15 字。
- 中文输入法合成阶段不截断,合成结束后才校验长度。
- 首页片头标题修改会同步写入 `vigent_${storageKey}_publish_title`
- 标题显示模式使用 `short` / `persistent` 两个固定值;默认 `short`(短暂显示 4 秒)。
- 避免使用 `maxLength` 强制截断输入法合成态。
- 推荐使用 `@/shared/hooks/useTitleInput` 统一处理输入逻辑。
@@ -343,6 +468,7 @@ import { formatDate } from '@/shared/lib/media';
- 发布按钮在未选择任何平台时禁用
- 仅保留"立即发布",不再提供定时发布 UI/参数
- **作品选择持久化**:使用 `video.id`(稳定标识)而非 `video.path`(签名 URL进行选择、比较和 localStorage 存储。发布时根据 `id` 查找对应 `path` 发送请求。
- **新作品优先级**:检测到“刚生成的新视频”时,页面首次恢复优先选中最新视频;之后用户手动改选会继续按持久化值恢复。
---
@@ -394,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 });
@@ -409,5 +539,5 @@ const mediaRecorder = new MediaRecorder(stream, { mimeType: 'audio/webm' });
### UI 结构
配音方式使用 Tab 切换:
- **EdgeTTS 音色** - 预设音色 2x3 网格
- **声音克隆** - 参考音频列表 + 在线录音 + 语速下拉菜单 (5 档: 较慢/稍慢/正常/稍快/较快)
- **EdgeTTS 音色** - 统一下拉选择(显示“音色名 + 语言”)
- **声音克隆** - 参考音频选择器(含试听/重命名/删除/重识别)+ 底部右侧上传/录音入口(录音弹窗)+ 语速/语气下拉

View File

@@ -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 字,视频开头显示 3 秒淡入淡出标题
### 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`

View File

@@ -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`,现已改为检测 FPS25fps 时跳过(我们的 `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
View 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
View 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`:平台发布自动化(抖音/微信/小红书基于 PlaywrightB站基于 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`

View File

@@ -1,6 +1,10 @@
# Qwen3-TTS 1.7B 部署指南
> 本文档描述如何在 Ubuntu 服务器上部署 Qwen3-TTS 1.7B-Base 声音克隆模型。
>
> ⚠️ **状态:历史归档(已停用)**
> 当前项目生产环境已切换到 CosyVoice 3.0,请优先参考 `Docs/COSYVOICE3_DEPLOY.md`。
> 本文档仅保留用于回溯旧方案,不建议新部署继续使用。
## 系统要求

View File

@@ -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,7 +187,9 @@ Remotion 渲染参数在 `backend/app/services/remotion_service.py` 中配置:
| 参数 | 默认值 | 说明 |
|------|--------|------|
| `fps` | 25 | 输出帧率 |
| `title_duration` | 3.0 | 标题显示时长(秒 |
| `concurrency` | 4 | Remotion 并发渲染进程数(默认 4可通过 `--concurrency` CLI 参数覆盖 |
| `title_display_mode` | `short` | 标题显示模式(`short`=短暂显示;`persistent`=常驻显示) |
| `title_duration` | 4.0 | 标题显示时长(秒,仅 `short` 模式生效) |
---
@@ -237,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 渲染失败
查看后端日志:
@@ -272,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 0MuseTalk 共享 GPU 0LatentSync 使用 GPU 1,互不冲突。如需指定 GPU
```python
# 在 whisper_service.py 中修改
@@ -288,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 提升到 16GPU 分配说明更新 |
| 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→4Whisper 时间戳平滑 + 原文节奏映射;全局视频生成 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 |

View File

@@ -1,8 +1,8 @@
# ViGent2 开发任务清单 (Task Log)
**项目**: ViGent2 数字人口播视频生成系统
**进度**: 100% (Day 24 - 鉴权到期治理 + 多素材时间轴稳定性修复)
**更新时间**: 2026-02-11
**进度**: 100% (Day 32 - 视频下载同源修复 + 清理链路体验收敛)
**更新时间**: 2026-03-04
---
@@ -10,16 +10,129 @@
> 这里记录了每一天的核心开发内容与 milestone。
### Day 24: 鉴权到期治理 + 多素材时间轴稳定性修复 (Current)
- [x] **会员到期请求时失效**: 登录与鉴权接口统一执行 `expires_at` 检查;到期后自动停用账号、清理 session并返回“会员已到期请续费”
- [x] **画面比例控制**: 时间轴新增 `9:16 / 16:9` 输出比例选择,前端持久化并透传后端,单素材/多素材统一按目标分辨率处理
- [x] **标题/字幕防溢出**: Remotion 与前端预览统一响应式缩放、自动换行、描边/字距/边距比例缩放,降低预览与成片差异
- [x] **MOV 方向归一化**: 新增旋转元数据解析与 orientation normalize修复“编码横屏+旋转元数据”导致的竖屏判断偏差
- [x] **多素材拼接稳定性**: 片段 prepare 与 concat 统一 25fps/CFRconcat 增加 `+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长视频走 MuseTalkMuseTalk 不可用时自动回退。
- [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` ref1 秒内禁止自动滚动)+ 延迟兜底 `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 超时从 1200s20 分钟)改为 3600s1 小时),修复 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/CFRconcat 增加 `+genpts`,缓解段切换处“画面冻结口型还动”。
- [x] **时间轴语义对齐**: 打通 `source_end` 全链路;修复 `sourceStart>0 且 sourceEnd=0` 时长计算;生成时以时间轴可见段 assignments 为准,超出段不参与。
- [x] **交互细节优化**: 页面刷新回顶部;素材/历史列表首轮自动滚动抑制,减少恢复状态时页面跳动。
### Day 23: 配音前置重构 + 素材时间轴编排 + UI 体验优化 + 声音克隆增强
#### 第一阶段:配音前置
- [x] **配音生成独立化**: 新增 `generated_audios` 后端模块router/schemas/service5 个 API 端点,复用现有 TTSService / voice_clone_service / task_store。
@@ -209,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 |
---

View File

@@ -4,7 +4,7 @@
> 📹 **上传人物** · 🎙️ **输入文案** · 🎬 **一键成片**
基于 **LatentSync 1.6 + EdgeTTS** 的开源数字人口播视频生成系统。
基于 **LatentSync 1.6 + MuseTalk 1.5 混合唇形同步** 的开源数字人口播视频生成系统。
集成 **CosyVoice 3.0** 声音克隆与自动社交媒体发布功能。
[功能特性](#-功能特性) • [技术栈](#-技术栈) • [文档中心](#-文档中心) • [部署指南](Docs/DEPLOY_MANUAL.md)
@@ -16,25 +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效果) 字幕。
- 🎨 **样式预设** - 标题/字幕样式选择 + 预览 + 字号调节,支持自定义字体库。
- 🖼 **作品预览一致性** - 标题/字幕预览与 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 阻塞调用线程池化
---
@@ -43,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 | 混合部署架构 |
@@ -57,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/) - 每日开发进度与技术决策记录。
---
@@ -82,7 +93,8 @@ ViGent2/
├── frontend/ # Next.js 前端应用
├── remotion/ # Remotion 视频渲染 (标题/字幕合成)
├── models/ # AI 模型仓库
│ ├── LatentSync/ # 唇形同步服务
│ ├── LatentSync/ # 唇形同步服务 (GPU1, 短视频)
│ ├── MuseTalk/ # 唇形同步服务 (GPU0, 长视频)
│ └── CosyVoice/ # 声音克隆服务
└── Docs/ # 项目文档
```
@@ -97,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 | 数据库与认证网关 |

View File

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

View File

@@ -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" # 忽略未知的环境变量

View File

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

View File

@@ -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 token30 分钟有效)"""
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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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("无权删除此文件")

View File

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

View File

View 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()
)

View 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

View 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"]

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
from pydantic import BaseModel
from pydantic import BaseModel, Field
from typing import Optional, List, Literal
@@ -21,9 +21,15 @@ class GenerateRequest(BaseModel):
language: str = "zh-CN"
generated_audio_id: Optional[str] = None # 预生成配音 ID存在时跳过内联 TTS
title: Optional[str] = None
title_display_mode: Literal["short", "persistent"] = "short"
title_duration: float = 4.0
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
@@ -32,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)

View File

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

View File

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

View File

@@ -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"):
@@ -657,31 +753,44 @@ async def process_video_generation(task_id: str, req: GenerateRequest, user_id:
mapped = 87 + int(percent * 0.08)
_update_task(task_id, progress=mapped)
title_display_mode = (
req.title_display_mode
if req.title_display_mode in ("short", "persistent")
else "short"
)
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,
title_duration=3.0,
title_duration=title_duration,
title_display_mode=title_display_mode,
fps=25,
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

View 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()

View File

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

View File

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

View File

@@ -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'})")

View File

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

View File

@@ -7,6 +7,7 @@ import asyncio
import json
import os
import subprocess
from collections.abc import Callable
from pathlib import Path
from typing import Optional
from loguru import logger
@@ -29,12 +30,15 @@ class RemotionService:
output_path: str,
captions_path: Optional[str] = None,
title: Optional[str] = None,
title_duration: float = 3.0,
title_duration: float = 4.0,
title_display_mode: str = "short",
fps: int = 25,
enable_subtitles: bool = True,
subtitle_style: Optional[dict] = None,
title_style: Optional[dict] = None,
on_progress: Optional[callable] = None
secondary_title: Optional[str] = None,
secondary_title_style: Optional[dict] = None,
on_progress: Optional[Callable[[int], None]] = None
) -> str:
"""
使用 Remotion 渲染视频(添加字幕和标题)
@@ -45,6 +49,7 @@ class RemotionService:
captions_path: 字幕 JSON 文件路径Whisper 生成)
title: 视频标题(可选)
title_duration: 标题显示时长(秒)
title_display_mode: 标题显示模式short/persistent
fps: 帧率
enable_subtitles: 是否启用字幕
on_progress: 进度回调函数
@@ -66,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:
@@ -75,6 +81,7 @@ class RemotionService:
if title:
cmd.extend(["--title", title])
cmd.extend(["--titleDuration", str(title_duration)])
cmd.extend(["--titleDisplayMode", title_display_mode])
if subtitle_style:
cmd.extend(["--subtitleStyle", json.dumps(subtitle_style, ensure_ascii=False)])
@@ -82,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)}")
# 在线程池中运行子进程
@@ -95,8 +108,12 @@ class RemotionService:
bufsize=1
)
if process.stdout is None:
raise RuntimeError("Remotion process stdout is unavailable")
stdout = process.stdout
output_lines = []
for line in iter(process.stdout.readline, ''):
for line in iter(stdout.readline, ''):
line = line.strip()
if line:
output_lines.append(line)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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
}
]

View File

@@ -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
}
]

View File

@@ -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
View 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
View File

@@ -0,0 +1,5 @@
{
"dependencies": {
"qrcode.react": "^4.2.0"
}
}

View File

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

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

View File

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

View File

@@ -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 || '登录失败');

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

View File

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

View File

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

View File

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

View File

@@ -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: "正在提交..." });

View File

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

View File

@@ -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 }[]> = {
@@ -87,6 +88,8 @@ const LANG_TO_LOCALE: Record<string, string> = {
"Português": "pt-BR",
};
const DEFAULT_SHORT_TITLE_DURATION = 4;
const scrollContainerToItem = (container: HTMLDivElement, item: HTMLDivElement) => {
@@ -121,6 +124,8 @@ interface RefAudio {
created_at: number;
}
type LipsyncModelMode = "default" | "fast" | "advanced";
import type { Material } from "@/shared/types/material";
export const useHomeController = () => {
@@ -149,11 +154,20 @@ export const useHomeController = () => {
const [subtitleSizeLocked, setSubtitleSizeLocked] = useState<boolean>(false);
const [titleSizeLocked, setTitleSizeLocked] = useState<boolean>(false);
const [titleTopMargin, setTitleTopMargin] = useState<number>(62);
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>("");
@@ -171,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);
@@ -270,6 +287,9 @@ export const useHomeController = () => {
// 文案提取模态框
const [extractModalOpen, setExtractModalOpen] = useState(false);
// AI 改写模态框
const [rewriteModalOpen, setRewriteModalOpen] = useState(false);
// 获取存储 key 的前缀(登录用户使用 userId未登录使用 guest
const storageKey = userId || "guest";
@@ -351,7 +371,7 @@ export const useHomeController = () => {
fetchGeneratedVideos,
deleteVideo,
} = useGeneratedVideos({
storageKey,
selectedVideoId,
setSelectedVideoId,
setGeneratedVideo,
@@ -385,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;
@@ -427,6 +460,8 @@ export const useHomeController = () => {
setText,
videoTitle,
setVideoTitle,
videoSecondaryTitle,
setVideoSecondaryTitle,
ttsMode,
setTtsMode,
voice,
@@ -439,18 +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,
@@ -464,6 +510,8 @@ export const useHomeController = () => {
setSelectedAudioId,
speed,
setSpeed,
emotion,
setEmotion,
});
const { savedScripts, saveScript, deleteScript: deleteSavedScript } = useSavedScripts(storageKey);
@@ -486,6 +534,12 @@ export const useHomeController = () => {
onCommit: syncTitleToPublish,
});
const secondaryTitleInput = useTitleInput({
value: videoSecondaryTitle,
onChange: setVideoSecondaryTitle,
maxLength: SECONDARY_TITLE_MAX_LENGTH,
});
// 加载素材列表和历史视频
useEffect(() => {
if (isAuthLoading) return;
@@ -577,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) {
@@ -589,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" });
@@ -623,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" });
@@ -671,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[] = [];
@@ -684,7 +751,6 @@ export const useHomeController = () => {
mediaRecorder.start();
setIsRecording(true);
setRecordingTime(0);
mediaRecorderRef.current = mediaRecorder;
// 计时器
@@ -720,6 +786,11 @@ export const useHomeController = () => {
setRecordingTime(0);
};
const discardRecording = () => {
setRecordedBlob(null);
setRecordingTime(0);
};
// 格式化录音时长
const formatRecordingTime = (seconds: number) => {
const mins = Math.floor(seconds / 60);
@@ -736,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() }
);
@@ -746,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) {
@@ -818,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,
@@ -826,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);
};
@@ -863,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,
@@ -937,15 +1021,33 @@ export const useHomeController = () => {
payload.title_font_size = Math.round(titleFontSize);
}
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;
}
// 创建生成任务
@@ -1021,6 +1123,8 @@ export const useHomeController = () => {
setText,
extractModalOpen,
setExtractModalOpen,
rewriteModalOpen,
setRewriteModalOpen,
handleGenerateMeta,
isGeneratingMeta,
handleTranslate,
@@ -1040,6 +1144,15 @@ export const useHomeController = () => {
titleFontSize,
setTitleFontSize,
setTitleSizeLocked,
videoSecondaryTitle,
secondaryTitleInput,
selectedSecondaryTitleStyleId,
setSelectedSecondaryTitleStyleId,
secondaryTitleFontSize,
setSecondaryTitleFontSize,
setSecondaryTitleSizeLocked,
secondaryTitleTopMargin,
setSecondaryTitleTopMargin,
subtitleStyles,
selectedSubtitleStyleId,
setSelectedSubtitleStyleId,
@@ -1048,14 +1161,19 @@ export const useHomeController = () => {
setSubtitleSizeLocked,
titleTopMargin,
setTitleTopMargin,
titleDisplayMode,
setTitleDisplayMode,
subtitleBottomMargin,
setSubtitleBottomMargin,
outputAspectRatio,
setOutputAspectRatio,
lipsyncModelMode,
setLipsyncModelMode,
resolveAssetUrl,
getFontFormat,
buildTextShadow,
materialDimensions,
materialPosterUrl,
ttsMode,
setTtsMode,
voices: VOICES[textLang] || VOICES["zh-CN"],
@@ -1087,6 +1205,7 @@ export const useHomeController = () => {
startRecording,
stopRecording,
useRecording,
discardRecording,
formatRecordingTime,
bgmList,
bgmLoading,
@@ -1125,6 +1244,8 @@ export const useHomeController = () => {
selectAudio,
speed,
setSpeed,
emotion,
setEmotion,
timelineSegments,
reorderSegments,
setSourceRange,

View File

@@ -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,18 +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;
@@ -54,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 = ({
@@ -63,6 +78,8 @@ export const useHomePersistence = ({
setText,
videoTitle,
setVideoTitle,
videoSecondaryTitle,
setVideoSecondaryTitle,
ttsMode,
setTtsMode,
voice,
@@ -75,18 +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,
@@ -100,6 +128,8 @@ export const useHomePersistence = ({
setSelectedAudioId,
speed,
setSpeed,
emotion,
setEmotion,
}: UseHomePersistenceOptions) => {
const [isRestored, setIsRestored] = useState(false);
@@ -108,26 +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);
@@ -147,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);
@@ -164,16 +204,33 @@ 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);
}
if (savedSubtitleBottomMargin) {
const parsed = parseInt(savedSubtitleBottomMargin, 10);
if (!Number.isNaN(parsed)) setSubtitleBottomMargin(parsed);
@@ -183,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);
}, [
@@ -198,20 +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,
]);
@@ -232,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]);
@@ -262,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));
@@ -274,12 +363,30 @@ 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);
}
}, [titleDisplayMode, storageKey, isRestored]);
useEffect(() => {
if (isRestored) {
localStorage.setItem(`vigent_${storageKey}_subtitleBottomMargin`, String(subtitleBottomMargin));
@@ -292,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);
@@ -342,5 +455,11 @@ export const useHomePersistence = ({
}
}, [speed, storageKey, isRestored]);
useEffect(() => {
if (isRestored) {
localStorage.setItem(`vigent_${storageKey}_emotion`, emotion);
}
}, [emotion, storageKey, isRestored]);
return { isRestored };
};

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,
@@ -80,8 +93,12 @@ export function HomePage() {
setTitleTopMargin,
subtitleBottomMargin,
setSubtitleBottomMargin,
titleDisplayMode,
setTitleDisplayMode,
outputAspectRatio,
setOutputAspectRatio,
lipsyncModelMode,
setLipsyncModelMode,
resolveAssetUrl,
getFontFormat,
buildTextShadow,
@@ -115,6 +132,7 @@ export function HomePage() {
startRecording,
stopRecording,
useRecording,
discardRecording,
formatRecordingTime,
bgmList,
bgmLoading,
@@ -126,8 +144,6 @@ export function HomePage() {
setSelectedBgmId,
playingBgmId,
toggleBgmPreview,
bgmVolume,
setBgmVolume,
bgmListContainerRef,
registerBgmItemRef,
currentTask,
@@ -153,6 +169,8 @@ export function HomePage() {
selectAudio,
speed,
setSpeed,
emotion,
setEmotion,
timelineSegments,
reorderSegments,
setSourceRange,
@@ -160,6 +178,7 @@ export function HomePage() {
setClipTrimmerOpen,
clipTrimmerSegmentId,
setClipTrimmerSegmentId,
materialPosterUrl,
} = useHomeController();
useEffect(() => {
@@ -168,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(
@@ -190,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}
@@ -207,100 +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}
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}
@@ -324,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}
@@ -361,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>
@@ -409,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}

View File

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

View File

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

View File

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

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

View File

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

View File

@@ -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"
>
&times;
</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>
);
}

View File

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

View File

@@ -1,5 +1,6 @@
import { 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;
@@ -52,25 +66,41 @@ interface TitleSubtitlePanelProps {
onTitleTopMarginChange: (value: number) => void;
subtitleBottomMargin: number;
onSubtitleBottomMarginChange: (value: number) => void;
titleDisplayMode: "short" | "persistent";
onTitleDisplayModeChange: (mode: "short" | "persistent") => void;
resolveAssetUrl: (path?: string | null) => string | null;
getFontFormat: (fontFile?: string) => string;
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,
@@ -80,34 +110,122 @@ export function TitleSubtitlePanel({
onTitleTopMarginChange,
subtitleBottomMargin,
onSubtitleBottomMarginChange,
titleDisplayMode,
onTitleDisplayModeChange,
resolveAssetUrl,
getFontFormat,
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}
@@ -119,11 +237,15 @@ export function TitleSubtitlePanel({
buildTextShadow={buildTextShadow}
previewBaseWidth={previewBaseWidth}
previewBaseHeight={previewBaseHeight}
previewBackgroundUrl={previewBackgroundUrl}
/>
)}
<div className="mb-4">
<label className="text-sm text-gray-300 mb-2 block">15</label>
<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"
value={videoTitle}
@@ -135,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>
)}

View File

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

View File

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

View File

@@ -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) => {

View File

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

View File

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

Some files were not shown because too many files have changed in this diff Show More