Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1717635bfd | ||
|
|
0a5a17402c | ||
|
|
bc0fe9326a | ||
|
|
035ee29d72 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -40,6 +40,7 @@ backend/uploads/
|
|||||||
backend/cookies/
|
backend/cookies/
|
||||||
backend/user_data/
|
backend/user_data/
|
||||||
backend/debug_screenshots/
|
backend/debug_screenshots/
|
||||||
|
backend/keys/
|
||||||
*_cookies.json
|
*_cookies.json
|
||||||
|
|
||||||
# ============ 模型权重 ============
|
# ============ 模型权重 ============
|
||||||
|
|||||||
278
Docs/ALIPAY_DEPLOY.md
Normal file
278
Docs/ALIPAY_DEPLOY.md
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
# 支付宝付费开通会员 — 部署指南
|
||||||
|
|
||||||
|
本文档涵盖支付宝电脑网站支付功能的完整部署流程。用户注册后通过支付宝付费自动激活会员,有效期 1 年。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 前置条件
|
||||||
|
|
||||||
|
- 支付宝企业/个体商户账号
|
||||||
|
- 已在 [支付宝开放平台](https://open.alipay.com) 创建应用并获取 APPID
|
||||||
|
- 应用已开通 **「电脑网站支付」** 产品权限(`alipay.trade.page.pay` 接口)
|
||||||
|
- 服务器域名已配置 HTTPS(支付宝回调要求公网可达)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 第一部分:支付宝开放平台配置
|
||||||
|
|
||||||
|
### 1. 创建应用
|
||||||
|
|
||||||
|
登录 https://open.alipay.com → 控制台 → 创建应用(或使用已有应用)。
|
||||||
|
|
||||||
|
### 2. 开通「电脑网站支付」产品
|
||||||
|
|
||||||
|
进入应用详情 → 产品绑定/产品管理 → 添加 **「电脑网站支付」** → 提交审核。
|
||||||
|
|
||||||
|
> **注意**:未开通此产品会导致 `ACQ.ACCESS_FORBIDDEN` 错误。
|
||||||
|
|
||||||
|
### 3. 生成密钥对
|
||||||
|
|
||||||
|
进入应用详情 → 开发设置 → 接口加签方式 → 选择 **RSA2(SHA256)**:
|
||||||
|
|
||||||
|
1. 使用支付宝官方密钥工具生成 RSA2048 密钥对
|
||||||
|
2. 将 **应用公钥** 上传到开放平台
|
||||||
|
3. 上传后平台会显示 **支付宝公钥**(`alipayPublicKey_RSA2`)
|
||||||
|
|
||||||
|
最终你会得到两样东西:
|
||||||
|
- **应用私钥**:你本地保存,代码用来签名请求
|
||||||
|
- **支付宝公钥**:平台返回给你,代码用来验证回调签名
|
||||||
|
|
||||||
|
> 应用公钥只是上传用的中间产物,代码中不需要。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 第二部分:服务器配置
|
||||||
|
|
||||||
|
### 1. 放置密钥文件
|
||||||
|
|
||||||
|
将密钥保存为标准 PEM 格式,放到 `backend/keys/` 目录:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p /home/rongye/ProgramFiles/ViGent2/backend/keys
|
||||||
|
```
|
||||||
|
|
||||||
|
**`backend/keys/app_private_key.pem`**(应用私钥):
|
||||||
|
|
||||||
|
```
|
||||||
|
-----BEGIN PRIVATE KEY-----
|
||||||
|
MIIEvQIBADANBgkqhkiG9w0BAQEFAASC...(你的私钥内容)
|
||||||
|
...
|
||||||
|
-----END PRIVATE KEY-----
|
||||||
|
```
|
||||||
|
|
||||||
|
**`backend/keys/alipay_public_key.pem`**(支付宝公钥):
|
||||||
|
|
||||||
|
```
|
||||||
|
-----BEGIN PUBLIC KEY-----
|
||||||
|
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A...(支付宝公钥内容)
|
||||||
|
...
|
||||||
|
-----END PUBLIC KEY-----
|
||||||
|
```
|
||||||
|
|
||||||
|
#### PEM 格式要求
|
||||||
|
|
||||||
|
支付宝密钥工具导出的是一行纯文本,需要转换为标准 PEM 格式:
|
||||||
|
|
||||||
|
- 必须有头尾标记(`-----BEGIN/END ...-----`)
|
||||||
|
- 密钥内容每 64 字符换行
|
||||||
|
- 私钥头标记为 `-----BEGIN PRIVATE KEY-----`(PKCS#8 格式)
|
||||||
|
- 公钥头标记为 `-----BEGIN PUBLIC KEY-----`
|
||||||
|
|
||||||
|
如果你拿到的是一行裸密钥,用以下命令转换:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 私钥格式化(假设裸密钥在 raw_private.txt 中)
|
||||||
|
echo "-----BEGIN PRIVATE KEY-----" > app_private_key.pem
|
||||||
|
cat raw_private.txt | fold -w 64 >> app_private_key.pem
|
||||||
|
echo "-----END PRIVATE KEY-----" >> app_private_key.pem
|
||||||
|
|
||||||
|
# 公钥格式化
|
||||||
|
echo "-----BEGIN PUBLIC KEY-----" > alipay_public_key.pem
|
||||||
|
cat raw_public.txt | fold -w 64 >> alipay_public_key.pem
|
||||||
|
echo "-----END PUBLIC KEY-----" >> alipay_public_key.pem
|
||||||
|
```
|
||||||
|
|
||||||
|
> `backend/keys/` 目录已加入 `.gitignore`,不会被提交到仓库。
|
||||||
|
|
||||||
|
### 2. 配置环境变量
|
||||||
|
|
||||||
|
在 `backend/.env` 中添加:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
# =============== 支付宝配置 ===============
|
||||||
|
ALIPAY_APP_ID=你的应用APPID
|
||||||
|
ALIPAY_PRIVATE_KEY_PATH=/home/rongye/ProgramFiles/ViGent2/backend/keys/app_private_key.pem
|
||||||
|
ALIPAY_PUBLIC_KEY_PATH=/home/rongye/ProgramFiles/ViGent2/backend/keys/alipay_public_key.pem
|
||||||
|
ALIPAY_NOTIFY_URL=https://vigent.hbyrkj.top/api/payment/notify
|
||||||
|
ALIPAY_RETURN_URL=https://vigent.hbyrkj.top/pay
|
||||||
|
```
|
||||||
|
|
||||||
|
| 变量 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `ALIPAY_APP_ID` | 支付宝开放平台应用 APPID |
|
||||||
|
| `ALIPAY_PRIVATE_KEY_PATH` | 应用私钥 PEM 文件绝对路径 |
|
||||||
|
| `ALIPAY_PUBLIC_KEY_PATH` | 支付宝公钥 PEM 文件绝对路径 |
|
||||||
|
| `ALIPAY_NOTIFY_URL` | 异步回调地址(服务器间通信),必须公网 HTTPS 可达 |
|
||||||
|
| `ALIPAY_RETURN_URL` | 同步跳转地址(用户支付完成后浏览器跳转回的页面) |
|
||||||
|
|
||||||
|
`config.py` 中还有几个可调参数(已有默认值,一般不需要加到 .env):
|
||||||
|
|
||||||
|
| 变量 | 默认值 | 说明 |
|
||||||
|
|------|--------|------|
|
||||||
|
| `ALIPAY_SANDBOX` | `false` | 是否使用沙箱环境 |
|
||||||
|
| `PAYMENT_AMOUNT` | `999.00` | 会员价格(元) |
|
||||||
|
| `PAYMENT_EXPIRE_DAYS` | `365` | 会员有效天数 |
|
||||||
|
|
||||||
|
### 3. 创建数据库表
|
||||||
|
|
||||||
|
通过 Docker 在本地 Supabase 中执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec -i supabase-db psql -U postgres -c "
|
||||||
|
CREATE TABLE IF NOT EXISTS orders (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
out_trade_no TEXT UNIQUE NOT NULL,
|
||||||
|
amount DECIMAL(10, 2) NOT NULL DEFAULT 999.00,
|
||||||
|
status TEXT DEFAULT 'pending' CHECK (status IN ('pending', 'paid', 'failed')),
|
||||||
|
trade_no TEXT,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
paid_at TIMESTAMP WITH TIME ZONE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_orders_user_id ON orders(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_orders_out_trade_no ON orders(out_trade_no);
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 安装依赖
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 后端(在 venv 中)
|
||||||
|
cd /home/rongye/ProgramFiles/ViGent2/backend
|
||||||
|
venv/bin/pip install python-alipay-sdk
|
||||||
|
```
|
||||||
|
|
||||||
|
> 前端无额外依赖需要安装。
|
||||||
|
|
||||||
|
### 5. Nginx 配置
|
||||||
|
|
||||||
|
确保 Nginx 将 `/api/payment/notify` 代理到后端。如果现有配置已覆盖 `/api/` 前缀,则无需额外修改:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://localhost:8006;
|
||||||
|
# ... 现有配置
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. 重启服务
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 构建前端
|
||||||
|
cd /home/rongye/ProgramFiles/ViGent2/frontend
|
||||||
|
npx next build
|
||||||
|
|
||||||
|
# 重启
|
||||||
|
pm2 restart vigent2-backend
|
||||||
|
pm2 restart vigent2-frontend
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 第三部分:正式上线
|
||||||
|
|
||||||
|
测试通过后,将 `backend/app/core/config.py` 中的测试金额改为正式价格:
|
||||||
|
|
||||||
|
```python
|
||||||
|
PAYMENT_AMOUNT: float = 999.00 # 正式价格
|
||||||
|
```
|
||||||
|
|
||||||
|
或在 `backend/.env` 中添加覆盖:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
PAYMENT_AMOUNT=999.00
|
||||||
|
```
|
||||||
|
|
||||||
|
然后重启后端:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pm2 restart vigent2-backend
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 支付流程说明
|
||||||
|
|
||||||
|
```
|
||||||
|
用户注册 → 登录(密码正确但 is_active=false)
|
||||||
|
→ 后端返回 403 + payment_token
|
||||||
|
→ 前端跳转 /pay 页面
|
||||||
|
→ POST /api/payment/create-order → 返回支付宝收银台 URL
|
||||||
|
→ 前端重定向到支付宝收银台页面(支持扫码、账号登录、余额等多种支付方式)
|
||||||
|
→ 用户完成支付
|
||||||
|
→ 支付宝异步回调 POST /api/payment/notify
|
||||||
|
→ 后端验签 → 更新订单 → 激活用户(is_active=true, expires_at=+365天)
|
||||||
|
→ 支付宝同步跳转回 /pay?out_trade_no=xxx
|
||||||
|
→ 前端轮询 GET /api/payment/status/{out_trade_no}
|
||||||
|
→ 轮询到 paid → 提示成功 → 跳转登录页
|
||||||
|
→ 用户重新登录 → 成功进入系统
|
||||||
|
```
|
||||||
|
|
||||||
|
**电脑网站支付 vs 当面付**:电脑网站支付(`alipay.trade.page.pay`)会跳转到支付宝官方收银台页面,用户可以选择扫码、支付宝账号登录、余额等多种方式支付,体验更好。当面付(`alipay.trade.precreate`)仅生成一个二维码,只能扫码支付。
|
||||||
|
|
||||||
|
会员到期续费同流程:登录时检测到过期 → 返回 PAYMENT_REQUIRED → 跳转 /pay。
|
||||||
|
|
||||||
|
管理员手动激活功能不受影响,两种方式并存。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 涉及文件
|
||||||
|
|
||||||
|
| 文件 | 变更类型 | 说明 |
|
||||||
|
|------|---------|------|
|
||||||
|
| `backend/requirements.txt` | 修改 | 添加 `python-alipay-sdk` |
|
||||||
|
| `backend/database/schema.sql` | 修改 | 新增 `orders` 表 |
|
||||||
|
| `backend/app/core/config.py` | 修改 | 支付宝配置项 |
|
||||||
|
| `backend/app/core/security.py` | 修改 | payment_token 函数 |
|
||||||
|
| `backend/app/core/deps.py` | 修改 | is_active 安全兜底 |
|
||||||
|
| `backend/app/repositories/orders.py` | 新建 | orders 数据层 |
|
||||||
|
| `backend/app/modules/payment/__init__.py` | 新建 | 模块初始化 |
|
||||||
|
| `backend/app/modules/payment/schemas.py` | 新建 | 请求/响应模型 |
|
||||||
|
| `backend/app/modules/payment/service.py` | 新建 | 支付业务逻辑(电脑网站支付) |
|
||||||
|
| `backend/app/modules/payment/router.py` | 新建 | 3 个 API 端点 |
|
||||||
|
| `backend/app/modules/auth/router.py` | 修改 | 登录返回 PAYMENT_REQUIRED |
|
||||||
|
| `backend/app/main.py` | 修改 | 注册 payment_router |
|
||||||
|
| `backend/.env` | 修改 | 支付宝环境变量 |
|
||||||
|
| `backend/keys/` | 新建 | PEM 密钥文件 |
|
||||||
|
| `frontend/src/shared/lib/auth.ts` | 修改 | login() 处理 paymentToken |
|
||||||
|
| `frontend/src/shared/api/axios.ts` | 修改 | PUBLIC_PATHS 加 /pay |
|
||||||
|
| `frontend/src/app/login/page.tsx` | 修改 | paymentToken 跳转 |
|
||||||
|
| `frontend/src/app/register/page.tsx` | 修改 | 注册成功提示文案 |
|
||||||
|
| `frontend/src/app/pay/page.tsx` | 新建 | 付费页面(重定向到支付宝收银台) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 常见问题
|
||||||
|
|
||||||
|
### RSA key format is not supported
|
||||||
|
|
||||||
|
密钥文件缺少 PEM 头尾标记或未按 64 字符换行。参考「PEM 格式要求」重新格式化。
|
||||||
|
|
||||||
|
### ACQ.ACCESS_FORBIDDEN
|
||||||
|
|
||||||
|
应用未开通「电脑网站支付」产品。在支付宝开放平台 → 应用详情 → 产品管理中添加并开通。
|
||||||
|
|
||||||
|
### 支付宝回调不到
|
||||||
|
|
||||||
|
1. 检查 `ALIPAY_NOTIFY_URL` 是否公网 HTTPS 可达
|
||||||
|
2. 检查 Nginx 是否将 `/api/payment/notify` 代理到后端
|
||||||
|
3. 支付宝回调超时(15s 未响应)会重试,共重试 8 次,持续 24 小时
|
||||||
|
|
||||||
|
### 支付完成后页面未跳转回来
|
||||||
|
|
||||||
|
检查 `ALIPAY_RETURN_URL` 配置是否正确,必须是前端 `/pay` 页面的完整 URL(如 `https://vigent.hbyrkj.top/pay`)。支付宝会在用户支付完成后将浏览器重定向到此地址,并附带 `out_trade_no` 等参数。
|
||||||
|
|
||||||
|
### 前端显示"网络错误"而非具体错误
|
||||||
|
|
||||||
|
API 函数缺少 try/catch 捕获 axios 异常。已在 `auth.ts` 的 `register()` 和 `login()` 中修复。
|
||||||
@@ -39,6 +39,7 @@ backend/
|
|||||||
│ │ ├── generated_audios/ # 预生成配音管理(router/schemas/service)
|
│ │ ├── generated_audios/ # 预生成配音管理(router/schemas/service)
|
||||||
│ │ ├── login_helper/ # 扫码登录辅助
|
│ │ ├── login_helper/ # 扫码登录辅助
|
||||||
│ │ ├── tools/ # 工具接口(router/schemas/service)
|
│ │ ├── tools/ # 工具接口(router/schemas/service)
|
||||||
|
│ │ ├── payment/ # 支付宝付费开通(router/schemas/service)
|
||||||
│ │ └── admin/ # 管理员功能
|
│ │ └── admin/ # 管理员功能
|
||||||
│ ├── repositories/ # Supabase 数据访问
|
│ ├── repositories/ # Supabase 数据访问
|
||||||
│ ├── services/ # 外部服务集成
|
│ ├── services/ # 外部服务集成
|
||||||
@@ -74,6 +75,18 @@ backend/
|
|||||||
- 错误通过 `HTTPException` 抛出,统一由全局异常处理返回 `{success:false, message, code}`。
|
- 错误通过 `HTTPException` 抛出,统一由全局异常处理返回 `{success:false, message, code}`。
|
||||||
- 不再使用 `detail` 作为前端错误文案(前端已改为读 `message`)。
|
- 不再使用 `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 侧需保持字段透传一致,避免前后端语义漂移。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. 认证与权限
|
## 4. 认证与权限
|
||||||
@@ -157,7 +170,13 @@ backend/user_data/{user_uuid}/cookies/
|
|||||||
- `DOUYIN_LOCALE` / `DOUYIN_TIMEZONE_ID`
|
- `DOUYIN_LOCALE` / `DOUYIN_TIMEZONE_ID`
|
||||||
- `DOUYIN_FORCE_SWIFTSHADER`
|
- `DOUYIN_FORCE_SWIFTSHADER`
|
||||||
- `DOUYIN_DEBUG_ARTIFACTS` / `DOUYIN_RECORD_VIDEO` / `DOUYIN_KEEP_SUCCESS_VIDEO`
|
- `DOUYIN_DEBUG_ARTIFACTS` / `DOUYIN_RECORD_VIDEO` / `DOUYIN_KEEP_SUCCESS_VIDEO`
|
||||||
- `DOUYIN_COOKIE` (抖音视频下载 Cookie)
|
|
||||||
|
### 支付宝
|
||||||
|
- `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)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ backend/
|
|||||||
│ │ ├── generated_audios/ # 预生成配音管理(router/schemas/service)
|
│ │ ├── generated_audios/ # 预生成配音管理(router/schemas/service)
|
||||||
│ │ ├── login_helper/ # 扫码登录辅助
|
│ │ ├── login_helper/ # 扫码登录辅助
|
||||||
│ │ ├── tools/ # 工具接口(router/schemas/service)
|
│ │ ├── tools/ # 工具接口(router/schemas/service)
|
||||||
|
│ │ ├── payment/ # 支付宝付费开通(router/schemas/service)
|
||||||
│ │ └── admin/ # 管理员功能
|
│ │ └── admin/ # 管理员功能
|
||||||
│ ├── repositories/ # Supabase 数据访问
|
│ ├── repositories/ # Supabase 数据访问
|
||||||
│ ├── services/ # 外部服务集成 (TTS/Remotion/Storage/Uploader 等)
|
│ ├── services/ # 外部服务集成 (TTS/Remotion/Storage/Uploader 等)
|
||||||
@@ -103,6 +104,13 @@ backend/
|
|||||||
* `GET /api/lipsync/health`: LatentSync 服务健康状态
|
* `GET /api/lipsync/health`: LatentSync 服务健康状态
|
||||||
* `GET /api/voiceclone/health`: CosyVoice 3.0 服务健康状态
|
* `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
|
```json
|
||||||
@@ -131,11 +139,17 @@ backend/
|
|||||||
- `output_aspect_ratio`: 输出画面比例(`9:16` 或 `16:9`,默认 `9:16`)
|
- `output_aspect_ratio`: 输出画面比例(`9:16` 或 `16:9`,默认 `9:16`)
|
||||||
- `language`: TTS 语言(默认自动检测,声音克隆时透传给 CosyVoice 3.0)
|
- `language`: TTS 语言(默认自动检测,声音克隆时透传给 CosyVoice 3.0)
|
||||||
- `title`: 片头标题文字
|
- `title`: 片头标题文字
|
||||||
|
- `title_display_mode`: 标题显示模式(`short` / `persistent`,默认 `short`)
|
||||||
|
- `title_duration`: 标题显示时长(秒,默认 `4.0`;`short` 模式生效)
|
||||||
- `subtitle_style_id`: 字幕样式 ID
|
- `subtitle_style_id`: 字幕样式 ID
|
||||||
- `title_style_id`: 标题样式 ID
|
- `title_style_id`: 标题样式 ID
|
||||||
- `subtitle_font_size`: 字幕字号(覆盖样式默认值)
|
- `subtitle_font_size`: 字幕字号(覆盖样式默认值)
|
||||||
- `title_font_size`: 标题字号(覆盖样式默认值)
|
- `title_font_size`: 标题字号(覆盖样式默认值)
|
||||||
- `title_top_margin`: 标题距顶部像素
|
- `title_top_margin`: 标题距顶部像素
|
||||||
|
- `secondary_title`: 片头副标题文字(可选,限 20 字,仅视频画面显示)
|
||||||
|
- `secondary_title_style_id`: 副标题样式 ID
|
||||||
|
- `secondary_title_font_size`: 副标题字号
|
||||||
|
- `secondary_title_top_margin`: 副标题距主标题间距
|
||||||
- `subtitle_bottom_margin`: 字幕距底部像素
|
- `subtitle_bottom_margin`: 字幕距底部像素
|
||||||
- `enable_subtitles`: 是否启用字幕
|
- `enable_subtitles`: 是否启用字幕
|
||||||
- `bgm_id`: 背景音乐 ID
|
- `bgm_id`: 背景音乐 ID
|
||||||
|
|||||||
@@ -213,6 +213,15 @@ cp .env.example .env
|
|||||||
| `DOUYIN_KEEP_SUCCESS_VIDEO` | false | 成功后保留录屏 |
|
| `DOUYIN_KEEP_SUCCESS_VIDEO` | false | 成功后保留录屏 |
|
||||||
| `CORS_ORIGINS` | `*` | CORS 允许源 (生产环境建议白名单) |
|
| `CORS_ORIGINS` | `*` | CORS 允许源 (生产环境建议白名单) |
|
||||||
| `DOUYIN_COOKIE` | 空 | 抖音视频下载 Cookie (文案提取功能) |
|
| `DOUYIN_COOKIE` | 空 | 抖音视频下载 Cookie (文案提取功能) |
|
||||||
|
| `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)**。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -558,6 +567,7 @@ pm2 logs vigent2-cosyvoice
|
|||||||
| `playwright` | 社交媒体自动发布 |
|
| `playwright` | 社交媒体自动发布 |
|
||||||
| `biliup` | B站视频上传 |
|
| `biliup` | B站视频上传 |
|
||||||
| `loguru` | 日志管理 |
|
| `loguru` | 日志管理 |
|
||||||
|
| `python-alipay-sdk` | 支付宝支付集成 |
|
||||||
|
|
||||||
### 前端关键依赖
|
### 前端关键依赖
|
||||||
|
|
||||||
|
|||||||
@@ -47,6 +47,16 @@
|
|||||||
- 开启可换行:`white-space: normal` + `word-break` + `overflow-wrap`。
|
- 开启可换行:`white-space: normal` + `word-break` + `overflow-wrap`。
|
||||||
- 描边、字距、上下边距同步按比例缩放。
|
- 描边、字距、上下边距同步按比例缩放。
|
||||||
|
|
||||||
|
### 2.3 片头标题显示模式(短暂/常驻)
|
||||||
|
|
||||||
|
- 在“标题与字幕”面板的“片头标题”行尾新增下拉,支持:`短暂显示` / `常驻显示`。
|
||||||
|
- 默认模式为 `短暂显示`,短暂模式默认时长为 4 秒。
|
||||||
|
- 用户选择会持久化到 localStorage,刷新后保持上次配置。
|
||||||
|
- 生成请求新增 `title_display_mode`,短暂模式透传 `title_duration=4.0`。
|
||||||
|
- Remotion 端到端支持该参数:
|
||||||
|
- `short`:标题在设定时长后淡出并结束渲染;
|
||||||
|
- `persistent`:标题全程常驻(保留淡入动画,不执行淡出)。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🎥 方向归一化 + 多素材拼接稳定性 — 第三阶段 (Day 24)
|
## 🎥 方向归一化 + 多素材拼接稳定性 — 第三阶段 (Day 24)
|
||||||
@@ -139,8 +149,9 @@
|
|||||||
| `backend/app/core/deps.py` | `get_current_user` / `get_current_user_optional` 接入到期失效检查 |
|
| `backend/app/core/deps.py` | `get_current_user` / `get_current_user_optional` 接入到期失效检查 |
|
||||||
| `backend/app/modules/auth/router.py` | 登录时到期停用 + `/api/auth/me` 统一鉴权依赖 |
|
| `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/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/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/useTimelineEditor.ts` | `CustomAssignment` 新增 `source_end`;修复 sourceStart 开放终点时长计算 |
|
||||||
| `frontend/src/features/home/model/useHomeController.ts` | 多素材以可见 assignments 为准发送;单素材截取触发条件补齐 |
|
| `frontend/src/features/home/model/useHomeController.ts` | 多素材以可见 assignments 为准发送;单素材截取触发条件补齐 |
|
||||||
| `frontend/src/features/home/ui/TimelineEditor.tsx` | 画面比例下拉;循环比例按截取后有效时长计算 |
|
| `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/HomePage.tsx` | 页面进入滚动到顶部;ClipTrimmer/Timeline 交互保持一致 |
|
||||||
| `frontend/src/features/home/ui/FloatingStylePreview.tsx` | 标题/字幕样式预览与成片渲染策略对齐 |
|
| `frontend/src/features/home/ui/FloatingStylePreview.tsx` | 标题/字幕样式预览与成片渲染策略对齐 |
|
||||||
|
| `frontend/src/features/home/ui/TitleSubtitlePanel.tsx` | 标题行新增“短暂显示/常驻显示”下拉 |
|
||||||
|
|
||||||
### Remotion 修改
|
### Remotion 修改
|
||||||
|
|
||||||
| 文件 | 变更 |
|
| 文件 | 变更 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| `remotion/src/components/Title.tsx` | 标题响应式缩放与自动换行,优化竖屏窄画布适配 |
|
| `remotion/src/components/Title.tsx` | 标题响应式缩放与自动换行;新增短暂/常驻显示模式控制 |
|
||||||
| `remotion/src/components/Subtitles.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`
|
- 前端类型检查:`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
254
Docs/DevLogs/Day25.md
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
## 🔧 文案提取助手修复 — 抖音链接无法提取文案 (Day 25)
|
||||||
|
|
||||||
|
### 概述
|
||||||
|
|
||||||
|
文案提取助手粘贴抖音链接后无法提取文案,yt-dlp 报错 `Fresh cookies are needed`,手动回退方案也因抖音页面结构变化失效。本日完成了完整修复,并清理了不再需要的 `DOUYIN_COOKIE` 配置。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🐛 问题诊断
|
||||||
|
|
||||||
|
### 错误链路
|
||||||
|
|
||||||
|
1. **yt-dlp 失败**:`ERROR: [Douyin] Fresh cookies (not necessarily logged in) are needed`
|
||||||
|
- yt-dlp 版本 `2025.12.08` 过旧
|
||||||
|
- 抖音 API `aweme/v1/web/aweme/detail/` 需要签名 cookie(`s_v_web_id` 等),即使升级 yt-dlp 到最新版 + 传入 cookie 仍无法解决,属 yt-dlp 已知问题
|
||||||
|
2. **手动回退失败**:`Could not find RENDER_DATA in page`
|
||||||
|
- 旧方案通过桌面端用户主页 + `modal_id` 访问,抖音 SSR 已不再返回 `videoDetail` 数据
|
||||||
|
3. **`.env` 中 `DOUYIN_COOKIE`**:时间戳 2024 年 12 月,早已过期
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ 修复方案:移动端分享页 + 自动获取 ttwid
|
||||||
|
|
||||||
|
### 核心思路
|
||||||
|
|
||||||
|
放弃依赖 yt-dlp 下载抖音视频和手动维护 cookie,改为:
|
||||||
|
|
||||||
|
1. 自动从 ByteDance 公共 API 获取新鲜 `ttwid`(匿名令牌,不绑定账号)
|
||||||
|
2. 用 `ttwid` 访问移动端分享页 `m.douyin.com/share/video/{id}`
|
||||||
|
3. 从页面内嵌 JSON 中提取 `play_addr` 播放地址并下载
|
||||||
|
|
||||||
|
### 关键代码(`_download_douyin_manual` 重写)
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 1. 获取新鲜 ttwid
|
||||||
|
ttwid_resp = await client.post(
|
||||||
|
"https://ttwid.bytedance.com/ttwid/union/register/",
|
||||||
|
json={"region": "cn", "aid": 6383, "service": "www.douyin.com", ...}
|
||||||
|
)
|
||||||
|
ttwid = ttwid_resp.cookies.get("ttwid", "")
|
||||||
|
|
||||||
|
# 2. 访问移动端分享页
|
||||||
|
page_resp = await client.get(
|
||||||
|
f"https://m.douyin.com/share/video/{video_id}",
|
||||||
|
headers={"cookie": f"ttwid={ttwid}", ...}
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3. 提取 play_addr
|
||||||
|
addr_match = re.search(r'"play_addr":\{"uri":"([^"]+)","url_list":\["([^"]+)"', page_text)
|
||||||
|
video_url = addr_match.group(2).replace(r"\u002F", "/")
|
||||||
|
```
|
||||||
|
|
||||||
|
### 优势
|
||||||
|
|
||||||
|
- 不再依赖手动维护的 `DOUYIN_COOKIE`,ttwid 每次请求自动获取
|
||||||
|
- 不受 yt-dlp 对抖音支持状况影响
|
||||||
|
- 所有用户通用,不绑定特定账号
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧹 清理 DOUYIN_COOKIE 配置
|
||||||
|
|
||||||
|
`DOUYIN_COOKIE` 仅用于文案提取,新方案不再需要,已从以下位置删除:
|
||||||
|
|
||||||
|
| 文件 | 变更 |
|
||||||
|
|------|------|
|
||||||
|
| `backend/.env` | 删除 `DOUYIN_COOKIE` 配置项及注释 |
|
||||||
|
| `backend/app/core/config.py` | 删除 `DOUYIN_COOKIE: str = ""` 字段定义 |
|
||||||
|
| `backend/app/modules/tools/service.py` | 删除 yt-dlp 传 cookie 逻辑和 `_write_netscape_cookies` 辅助函数 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔤 前端文案修正
|
||||||
|
|
||||||
|
将文案提取界面中的"AI 洗稿结果"改为"AI 改写结果"。
|
||||||
|
|
||||||
|
| 文件 | 变更 |
|
||||||
|
|------|------|
|
||||||
|
| `frontend/src/features/home/ui/ScriptExtractionModal.tsx` | `AI 洗稿结果` → `AI 改写结果` |
|
||||||
|
| `backend/app/modules/tools/service.py` | 注释中"洗稿"→"改写" |
|
||||||
|
| `backend/app/services/glm_service.py` | docstring 中"洗稿"→"改写文案" |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📦 其他变更
|
||||||
|
|
||||||
|
- **yt-dlp 升级**:`2025.12.08` → `2026.2.21`
|
||||||
|
- **yt-dlp 初始化修正**:改为 `YoutubeDL(ydl_opts)` 直接传参初始化(原先空初始化后 update params 不生效)
|
||||||
|
- **User-Agent 更新**:yt-dlp 中 `Chrome/91` → `Chrome/131`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 涉及文件汇总
|
||||||
|
|
||||||
|
### 后端修改
|
||||||
|
|
||||||
|
| 文件 | 变更 |
|
||||||
|
|------|------|
|
||||||
|
| `backend/app/modules/tools/service.py` | 重写 `_download_douyin_manual`(移动端分享页方案);修正 yt-dlp 初始化;清理 cookie 相关代码;注释改写 |
|
||||||
|
| `backend/app/services/glm_service.py` | docstring "洗稿" → "改写文案" |
|
||||||
|
| `backend/app/core/config.py` | 删除 `DOUYIN_COOKIE` 字段 |
|
||||||
|
| `backend/.env` | 删除 `DOUYIN_COOKIE` 配置 |
|
||||||
|
| `backend/requirements.txt` | yt-dlp 版本升级 |
|
||||||
|
|
||||||
|
### 前端修改
|
||||||
|
|
||||||
|
| 文件 | 变更 |
|
||||||
|
|------|------|
|
||||||
|
| `frontend/src/features/home/ui/ScriptExtractionModal.tsx` | "AI 洗稿结果" → "AI 改写结果" |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✏️ AI 智能改写 — 自定义提示词功能
|
||||||
|
|
||||||
|
### 概述
|
||||||
|
|
||||||
|
文案提取助手的"AI 智能改写"原先使用硬编码 prompt,用户无法定制改写风格。本次在 checkbox 右侧新增"自定义提示词"折叠区域,用户可编辑自定义 prompt,持久化到 localStorage,后端按需替换默认 prompt。
|
||||||
|
|
||||||
|
### 后端修改
|
||||||
|
|
||||||
|
**路由层** (`router.py`):`extract_script_tool` 新增可选 Form 参数 `custom_prompt: Optional[str] = Form(None)`,透传给 service。
|
||||||
|
|
||||||
|
**服务层** (`service.py`):`extract_script()` 签名新增 `custom_prompt`,透传给 `glm_service.rewrite_script(script, custom_prompt)`。
|
||||||
|
|
||||||
|
**AI 层** (`glm_service.py`):`rewrite_script(self, text, custom_prompt=None)`,若 `custom_prompt` 有值则用自定义 prompt + 原文拼接,否则保持原有默认 prompt。
|
||||||
|
|
||||||
|
```python
|
||||||
|
if custom_prompt and custom_prompt.strip():
|
||||||
|
prompt = f"""{custom_prompt.strip()}
|
||||||
|
|
||||||
|
原始文案:
|
||||||
|
{text}"""
|
||||||
|
else:
|
||||||
|
prompt = f"""请将以下视频文案进行改写。...(原有默认)"""
|
||||||
|
```
|
||||||
|
|
||||||
|
### 前端修改
|
||||||
|
|
||||||
|
**Hook** (`useScriptExtraction.ts`):
|
||||||
|
- 新增 `customPrompt` / `showCustomPrompt` 状态
|
||||||
|
- 初始值从 `localStorage.getItem("vigent_rewriteCustomPrompt")` 恢复
|
||||||
|
- `customPrompt` 变化时防抖 300ms 保存到 localStorage
|
||||||
|
- `handleExtract()` 中若 `doRewrite && customPrompt.trim()` 有值,追加 `formData.append("custom_prompt", ...)`
|
||||||
|
- modal 重置时不清空 customPrompt(持久化偏好)
|
||||||
|
|
||||||
|
**UI** (`ScriptExtractionModal.tsx`):
|
||||||
|
- checkbox 同行右侧新增"自定义提示词 ▼"按钮(仅 `doRewrite` 时显示)
|
||||||
|
- 点击展开 textarea 编辑区域,底部提示"留空则使用默认提示词"
|
||||||
|
- 取消勾选 AI 智能改写时,自定义提示词区域自动隐藏
|
||||||
|
|
||||||
|
### 涉及文件
|
||||||
|
|
||||||
|
| 文件 | 变更 |
|
||||||
|
|------|------|
|
||||||
|
| `backend/app/modules/tools/router.py` | 新增 `custom_prompt` Form 参数 |
|
||||||
|
| `backend/app/modules/tools/service.py` | `extract_script()` 透传 `custom_prompt` |
|
||||||
|
| `backend/app/services/glm_service.py` | `rewrite_script()` 支持自定义 prompt |
|
||||||
|
| `frontend/.../useScriptExtraction.ts` | 新增状态、localStorage 持久化、FormData 传参 |
|
||||||
|
| `frontend/.../ScriptExtractionModal.tsx` | UI 按钮 + 展开 textarea |
|
||||||
|
|
||||||
|
### 验证
|
||||||
|
|
||||||
|
- 后端 `python -m py_compile` 三个文件通过
|
||||||
|
- 前端 `npx tsc --noEmit` 通过
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🐛 SSR 构建修复 — localStorage is not defined
|
||||||
|
|
||||||
|
### 问题
|
||||||
|
|
||||||
|
`npm run build` 报错 `ReferenceError: localStorage is not defined`,因为 `useScriptExtraction.ts` 中 `useState` 的初始化函数在 SSR(Node.js)环境下也会执行,而服务端没有 `localStorage`。
|
||||||
|
|
||||||
|
### 修复
|
||||||
|
|
||||||
|
`useState` 初始化加 `typeof window !== "undefined"` 守卫:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const [customPrompt, setCustomPrompt] = useState(
|
||||||
|
() => typeof window !== "undefined" ? localStorage.getItem(CUSTOM_PROMPT_KEY) || "" : ""
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
| 文件 | 变更 |
|
||||||
|
|------|------|
|
||||||
|
| `frontend/.../useScriptExtraction.ts` | `useState` 初始化增加 SSR 安全守卫 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎬 片头副标题功能
|
||||||
|
|
||||||
|
### 概述
|
||||||
|
|
||||||
|
新增片头副标题(secondary_title),显示在主标题下方,用于补充说明或悬念引导。副标题有独立的样式配置(字体、字号、颜色等),可由 AI 同时生成,20 字限制,仅在视频画面中显示,不参与发布标题。
|
||||||
|
|
||||||
|
命名约定:后端 `secondary_title`(snake_case),前端 `videoSecondaryTitle`(camelCase),用户界面"片头副标题"。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 后端修改
|
||||||
|
|
||||||
|
| 文件 | 变更 |
|
||||||
|
|------|------|
|
||||||
|
| `backend/app/modules/videos/schemas.py` | `GenerateRequest` 新增 4 个可选字段:`secondary_title`、`secondary_title_style_id`、`secondary_title_font_size`、`secondary_title_top_margin` |
|
||||||
|
| `backend/app/services/glm_service.py` | AI prompt 增加副标题生成要求(不超过20字),JSON 格式新增 `secondary_title` 字段 |
|
||||||
|
| `backend/app/modules/ai/router.py` | `GenerateMetaResponse` 增加 `secondary_title: str = ""`,endpoint 返回时取 `result.get("secondary_title", "")` |
|
||||||
|
| `backend/app/modules/videos/workflow.py` | `use_remotion` 条件增加 `or req.secondary_title`;副标题样式解析复用 `get_style("title", ...)`;字号/间距覆盖;`prepare_style_for_remotion` 处理副标题字体;`remotion_service.render()` 传入 `secondary_title` + `secondary_title_style` |
|
||||||
|
| `backend/app/services/remotion_service.py` | `render()` 新增 `secondary_title` 和 `secondary_title_style` 参数,构建 CLI 参数 `--secondaryTitle` 和 `--secondaryTitleStyle` |
|
||||||
|
|
||||||
|
### Remotion 修改
|
||||||
|
|
||||||
|
| 文件 | 变更 |
|
||||||
|
|------|------|
|
||||||
|
| `remotion/render.ts` | `RenderOptions` 新增 `secondaryTitle?` + `secondaryTitleStyle?`;`parseArgs()` 新增 switch case;`inputProps` 新增两个字段 |
|
||||||
|
| `remotion/src/components/Title.tsx` | `TitleProps` 新增 `secondaryTitle?` 和 `secondaryTitleStyle?`;`AbsoluteFill` 改为 `flexDirection: 'column'` 垂直堆叠;主标题 `<h1>` 后增加副标题 `<h2>`,独立样式(默认字号 48px、字重 700),共享淡入淡出动画;副标题字体使用独立 `@font-face`(`SecondaryTitleFont`)避免与主标题冲突 |
|
||||||
|
| `remotion/src/Video.tsx` | `VideoProps` 新增 `secondaryTitle?` + `secondaryTitleStyle?`;传递给 `<Title>` 组件;渲染条件改为 `{(title \|\| secondaryTitle) && ...}` |
|
||||||
|
| `remotion/src/Root.tsx` | `defaultProps` 新增 `secondaryTitle: undefined` + `secondaryTitleStyle: undefined` |
|
||||||
|
|
||||||
|
### 前端修改
|
||||||
|
|
||||||
|
| 文件 | 变更 |
|
||||||
|
|------|------|
|
||||||
|
| `frontend/src/shared/lib/title.ts` | 新增 `SECONDARY_TITLE_MAX_LENGTH = 20` 和 `clampSecondaryTitle()` |
|
||||||
|
| `frontend/src/features/home/model/useHomeController.ts` | 新增状态 `videoSecondaryTitle`、`selectedSecondaryTitleStyleId`、`secondaryTitleFontSize`、`secondaryTitleTopMargin`、`secondaryTitleSizeLocked`;新建 `secondaryTitleInput = useTitleInput({ maxLength: 20 })`(不 sync 到发布页);`handleGenerateMeta()` 接收并填充 `secondary_title`;`handleGenerate()` 构建 payload 增加副标题字段;return 暴露所有新状态 |
|
||||||
|
| `frontend/src/features/home/model/useHomePersistence.ts` | 新增 localStorage key:`secondaryTitle`、`secondaryTitleStyle`、`secondaryTitleFontSize`、`secondaryTitleTopMargin`;对应的恢复和保存 effect |
|
||||||
|
| `frontend/src/features/home/ui/TitleSubtitlePanel.tsx` | Props 新增副标题相关;主标题输入框下方添加"片头副标题(限制20个字)"输入框;副标题样式选择器(复用 titleStyles 预设)、字号滑块(30-100px)、间距滑块(0-100px) |
|
||||||
|
| `frontend/src/features/home/ui/FloatingStylePreview.tsx` | 标题预览改为 flex column 布局;主标题下方增加副标题预览行,独立样式渲染 |
|
||||||
|
| `frontend/src/features/home/ui/HomePage.tsx` | 从 `useHomeController` 解构新状态,传给 `TitleSubtitlePanel` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🐛 参考音频上传 — 中文文件名 InvalidKey 修复
|
||||||
|
|
||||||
|
### 问题
|
||||||
|
|
||||||
|
上传中文名参考音频(如"我的声音.wav")时,Supabase Storage 报 `InvalidKey`,因为存储路径直接使用了原始中文文件名。
|
||||||
|
|
||||||
|
### 修复
|
||||||
|
|
||||||
|
在 `ref_audios/service.py` 新增 `sanitize_filename()` 函数,将存储路径的文件名清洗为 ASCII 安全字符(仅 `A-Za-z0-9._-`):
|
||||||
|
|
||||||
|
- NFKD 规范化 → 丢弃非 ASCII → 非法字符替换为 `_`
|
||||||
|
- 纯中文/emoji 清洗后为空时,使用 MD5 哈希兜底(如 `audio_e924b1193007`)
|
||||||
|
- 文件名限长 50 字符
|
||||||
|
- 原始中文文件名保留在 metadata 中作为展示名,前端显示不受影响
|
||||||
|
|
||||||
|
```
|
||||||
|
修复前: cbbe.../1771915755_我的声音.wav → InvalidKey
|
||||||
|
修复后: cbbe.../1771915755_audio_xxxxxxxx.wav → 上传成功
|
||||||
|
```
|
||||||
|
|
||||||
|
| 文件 | 变更 |
|
||||||
|
|------|------|
|
||||||
|
| `backend/app/modules/ref_audios/service.py` | 新增 `sanitize_filename()` 函数,上传路径使用清洗后文件名 |
|
||||||
239
Docs/DevLogs/Day26.md
Normal file
239
Docs/DevLogs/Day26.md
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
## 🎨 前端优化:板块合并 + 序号标题 + UI 精细化 (Day 26)
|
||||||
|
|
||||||
|
### 概述
|
||||||
|
|
||||||
|
首页原有 9 个独立板块(左栏 7 个 + 右栏 2 个),每个都有自己的卡片容器和标题,视觉碎片化严重。本次将相关板块合并为 5 个主板块,添加中文序号(一~十),移除 emoji 图标,并对多个子组件的布局和交互细节进行优化。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ 改动内容
|
||||||
|
|
||||||
|
### 1. 板块合并方案
|
||||||
|
|
||||||
|
**左栏(4 个主板块 + 2 个独立区域):**
|
||||||
|
|
||||||
|
| 序号 | 板块名 | 子板块 | 原组件 |
|
||||||
|
|------|--------|--------|--------|
|
||||||
|
| 一 | 文案提取与编辑 | — | ScriptEditor |
|
||||||
|
| 二 | 标题与字幕 | — | TitleSubtitlePanel |
|
||||||
|
| 三 | 配音 | 配音方式 / 配音列表 | VoiceSelector + GeneratedAudiosPanel |
|
||||||
|
| 四 | 素材编辑 | 视频素材 / 时间轴编辑 | MaterialSelector + TimelineEditor |
|
||||||
|
| 五 | 背景音乐 | — | BgmPanel |
|
||||||
|
| — | 生成按钮 | — | GenerateActionBar(不编号) |
|
||||||
|
|
||||||
|
**右栏(1 个主板块):**
|
||||||
|
|
||||||
|
| 序号 | 板块名 | 子板块 | 原组件 |
|
||||||
|
|------|--------|--------|--------|
|
||||||
|
| 六 | 作品 | 作品列表 / 作品预览 | HistoryList + PreviewPanel |
|
||||||
|
|
||||||
|
**发布页(/publish):**
|
||||||
|
|
||||||
|
| 序号 | 板块名 |
|
||||||
|
|------|--------|
|
||||||
|
| 七 | 平台账号 |
|
||||||
|
| 八 | 选择发布作品 |
|
||||||
|
| 九 | 发布信息 |
|
||||||
|
| 十 | 选择发布平台 |
|
||||||
|
|
||||||
|
### 2. embedded 模式
|
||||||
|
|
||||||
|
6 个组件新增 `embedded?: boolean` prop(默认 `false`):
|
||||||
|
|
||||||
|
- `VoiceSelector` — embedded 时不渲染外层卡片和主标题
|
||||||
|
- `GeneratedAudiosPanel` — embedded 时两行布局:第 1 行(语速+生成配音右对齐)、第 2 行(配音列表+刷新)
|
||||||
|
- `MaterialSelector` — embedded 时自渲染 h3 子标题"视频素材"+ 上传/刷新按钮同行
|
||||||
|
- `TimelineEditor` — embedded 时自渲染 h3 子标题"时间轴编辑"+ 画面比例/播放控件同行
|
||||||
|
- `PreviewPanel` — embedded 时不渲染外层卡片和标题
|
||||||
|
- `HistoryList` — embedded 时不渲染外层卡片和标题(刷新按钮由 HomePage 提供)
|
||||||
|
|
||||||
|
### 3. 序号标题 + emoji 移除
|
||||||
|
|
||||||
|
所有编号板块移除 emoji 图标,使用纯中文序号:
|
||||||
|
|
||||||
|
- ScriptEditor: `✍️ 文案提取与编辑` → `一、文案提取与编辑`
|
||||||
|
- TitleSubtitlePanel: `🎬 标题与字幕` → `二、标题与字幕`
|
||||||
|
- BgmPanel: `🎵 背景音乐` → `五、背景音乐`
|
||||||
|
- HomePage 右栏: `五、作品` → `六、作品`
|
||||||
|
- PublishPage: `👤 平台账号` → `七、平台账号`、`📹 选择发布作品` → `八、选择发布作品`、`✍️ 发布信息` → `九、发布信息`、`📱 选择发布平台` → `十、选择发布平台`
|
||||||
|
|
||||||
|
### 4. 子标题与分隔样式
|
||||||
|
|
||||||
|
- **主标题**: `text-base sm:text-lg font-semibold text-white`
|
||||||
|
- **子标题**: `text-sm font-medium text-gray-400`
|
||||||
|
- **分隔线**: `<div className="border-t border-white/10 my-4" />`
|
||||||
|
|
||||||
|
### 5. 配音列表布局优化
|
||||||
|
|
||||||
|
GeneratedAudiosPanel embedded 模式下采用两行布局:
|
||||||
|
- **第 1 行**:语速下拉 + 生成配音按钮(右对齐,`flex justify-end`)
|
||||||
|
- **第 2 行**:`<h3>配音列表</h3>` + 刷新按钮(两端对齐)
|
||||||
|
- 非 embedded 模式保持原单行布局
|
||||||
|
|
||||||
|
### 6. TitleSubtitlePanel 下拉对齐
|
||||||
|
|
||||||
|
- 标题样式/副标题样式/字幕样式三行标签统一 `w-20`(固定 80px),确保下拉菜单垂直对齐
|
||||||
|
- 下拉菜单宽度 `w-1/3 min-w-[100px]`,避免过宽
|
||||||
|
|
||||||
|
### 7. RefAudioPanel 文案简化
|
||||||
|
|
||||||
|
- 原底部段落"上传任意语音样本(3-10秒)…" 移至 "我的参考音频" 标题旁,简化为 `(上传3-10秒语音样本)`
|
||||||
|
|
||||||
|
### 8. 账户下拉菜单添加手机号
|
||||||
|
|
||||||
|
- AccountSettingsDropdown 在账户有效期上方新增手机号显示区域
|
||||||
|
- 显示 `user?.phone || '未知账户'`
|
||||||
|
|
||||||
|
### 9. 标题显示模式对副标题生效
|
||||||
|
|
||||||
|
- **payload 修复**: `useHomeController.ts` 中 `title_display_mode` 的发送条件从 `videoTitle.trim()` 改为 `videoTitle.trim() || videoSecondaryTitle.trim()`,确保仅有副标题时也能发送显示模式
|
||||||
|
- **UI 调整**: 短暂显示/常驻显示下拉从片头标题输入行移至"二、标题与字幕"板块标题行(与预览样式按钮同行),明确表示该设置对标题和副标题同时生效
|
||||||
|
- Remotion 端 `Title.tsx` 已支持(标题和副标题作为整体组件渲染,`displayMode` 统一控制)
|
||||||
|
|
||||||
|
### 10. 时间轴模糊遮罩
|
||||||
|
|
||||||
|
遮罩从外层 wrapper 移入"四、素材编辑"卡片内,仅覆盖时间轴子区域(`rounded-xl`)。
|
||||||
|
|
||||||
|
### 11. 登录后用户信息立即可用
|
||||||
|
|
||||||
|
- AuthContext 新增 `setUser` 方法暴露给消费者
|
||||||
|
- 登录页成功后调用 `setUser(result.user)` 立即写入 Context,无需等页面刷新
|
||||||
|
- 修复登录后账户下拉显示"未知账户"、刷新后才显示手机号的问题
|
||||||
|
|
||||||
|
### 12. 文案与选项微调
|
||||||
|
|
||||||
|
- MaterialSelector 描述 `(可多选,最多4个)` → `(上传自拍视频,最多可选4个)`
|
||||||
|
- TitleSubtitlePanel 显示模式选项 `短暂显示/常驻显示` → `标题短暂显示/标题常驻显示`
|
||||||
|
|
||||||
|
### 13. UI/UX 体验优化(6 项)
|
||||||
|
|
||||||
|
- **操作按钮移动端可见**: 配音列表、作品列表、素材列表、参考音频、历史文案的操作按钮从 `opacity-0`(hover 才显示)改为 `opacity-40`(平时半透明可见,hover 全亮),解决触屏设备无法发现按钮的问题
|
||||||
|
- **手机号脱敏**: AccountSettingsDropdown 手机号中间四位遮掩 `138****5678`
|
||||||
|
- **标题字数计数器**: TitleSubtitlePanel 标题/副标题输入框右侧显示实时字数 `3/15`,超限变红
|
||||||
|
- **列表滚动条提示**: ~~配音列表、作品列表、素材列表、BGM 列表从 `hide-scrollbar` 改为 `custom-scrollbar`~~ → 已全部改回 `hide-scrollbar` 隐藏滚动条(滚动功能不变)
|
||||||
|
- **时间轴拖拽提示**: TimelineEditor 色块左上角新增 `GripVertical` 抓手图标,暗示可拖拽排序
|
||||||
|
- **截取滑块放大**: ClipTrimmer 手柄从 16px 放大到 20px,触控区从 32px 放大到 40px
|
||||||
|
|
||||||
|
### 14. 代码质量修复(4 项)
|
||||||
|
|
||||||
|
- **AccountSettingsDropdown**: 关闭密码弹窗补齐 `setSuccess('')` 清空
|
||||||
|
- **MaterialSelector**: `selectedSet` 加 `useMemo` 避免每次渲染重建
|
||||||
|
- **TimelineEditor**: `visibleSegments`/`overflowSegments` 加 `useMemo`
|
||||||
|
- **MaterialSelector**: 素材满 4 个时非选中项按钮加 `disabled`
|
||||||
|
|
||||||
|
### 15. 发布页平台账号响应式布局
|
||||||
|
|
||||||
|
- **单行布局**:图标+名称+状态在左,按钮在右(`flex items-center`)
|
||||||
|
- **移动端紧凑**:图标 `h-6 w-6`、按钮 `text-xs px-2 py-1 rounded-md`、间距 `space-y-2 px-3 py-2.5`
|
||||||
|
- **桌面端宽松**:`sm:h-7 sm:w-7`、`sm:text-sm sm:px-3 sm:py-1.5 sm:rounded-lg`、`sm:space-y-3 sm:px-4 sm:py-3.5`
|
||||||
|
- 两端各自美观,风格与其他板块一致
|
||||||
|
|
||||||
|
### 16. 移动端刷新回顶部修复
|
||||||
|
|
||||||
|
- **问题**: 移动端刷新页面后不回到顶部,而是滚动到背景音乐板块
|
||||||
|
- **根因**: 1) 浏览器原生滚动恢复覆盖 `scrollTo(0,0)`;2) 列表 scroll effect 有双依赖(`selectedId` + `list`),数据异步加载时第二次触发跳过了 ref 守卫,执行了 `scrollIntoView` 导致页面跳动
|
||||||
|
- **修复**: 三管齐下 — ① `history.scrollRestoration = "manual"` 禁用浏览器原生恢复;② 时间门控 `scrollEffectsEnabled` ref(1 秒内禁止所有列表自动滚动)替代单次 ref 守卫;③ 200ms 延迟兜底 `scrollTo(0,0)`
|
||||||
|
|
||||||
|
### 17. 移动端样式预览窗口缩小
|
||||||
|
|
||||||
|
- **问题**: 移动端点击"预览样式"后窗口占满整屏(宽 358px,高约 636px),遮挡样式调节控件
|
||||||
|
- **修复**: 移动端宽度从 `window.innerWidth - 32` 缩小到 **160px**;位置从左上角改为**右下角**(`right:12, bottom:12`),不遮挡上方控件;最大高度限制 `50dvh`
|
||||||
|
- 桌面端保持不变(280px,左上角)
|
||||||
|
|
||||||
|
### 18. 列表滚动条统一隐藏
|
||||||
|
|
||||||
|
- 将 Day 26 早期改为 `custom-scrollbar`(细紫色滚动条)的 7 处全部改回 `hide-scrollbar`
|
||||||
|
- 涉及:BgmPanel、GeneratedAudiosPanel、HistoryList、MaterialSelector(2处)、ScriptExtractionModal(2处)
|
||||||
|
- 滚动功能不受影响,仅视觉上不显示滚动条
|
||||||
|
|
||||||
|
### 19. 配音按钮移动端适配
|
||||||
|
|
||||||
|
- VoiceSelector "选择声音/克隆声音" 按钮:内边距 `px-4` → `px-2 sm:px-4`,字号加 `text-sm sm:text-base`,图标加 `shrink-0`
|
||||||
|
- 修复移动端窄屏下按钮被挤压导致"克隆声音"不可见的问题
|
||||||
|
|
||||||
|
### 20. 素材标题溢出修复
|
||||||
|
|
||||||
|
- MaterialSelector embedded 标题行移除 `whitespace-nowrap`
|
||||||
|
- 描述文字 `(上传自拍视频,最多可选4个)` 在移动端隐藏(`hidden sm:inline`),桌面端正常显示
|
||||||
|
- 修复移动端刷新按钮被推出容器外的问题
|
||||||
|
|
||||||
|
### 21. 生成配音按钮放大
|
||||||
|
|
||||||
|
- "生成配音" 作为核心操作按钮,从辅助尺寸升级为主操作尺寸
|
||||||
|
- 内边距 `px-2/px-3 py-1/py-1.5` → `px-4 py-2`,字号 `text-xs` → `text-sm font-medium`
|
||||||
|
- 图标 `h-3.5 w-3.5` → `h-4 w-4`,新增 `shadow-sm` + hover `shadow-md`
|
||||||
|
- embedded 与非 embedded 模式统一放大
|
||||||
|
|
||||||
|
### 22. 生成进度条位置调整
|
||||||
|
|
||||||
|
- **问题**: 生成进度条在"六、作品"卡片内部(作品预览下方),不够醒目
|
||||||
|
- **修复**: 进度条从 PreviewPanel 内部提取到 HomePage 右栏,作为独立卡片渲染在"六、作品"卡片**上方**
|
||||||
|
- 使用紫色边框(`border-purple-500/30`)区分,显示任务消息和百分比
|
||||||
|
- PreviewPanel embedded 模式下不再渲染进度条(传入 `currentTask={null}`)
|
||||||
|
- 生成完成后进度卡片自动消失
|
||||||
|
|
||||||
|
### 23. LatentSync 超时修复
|
||||||
|
|
||||||
|
- **问题**: 约 2 分钟的视频(3023 帧,190 段推理)预计推理 54 分钟,但 httpx 超时仅 20 分钟,导致 LatentSync 调用失败并回退到无口型同步
|
||||||
|
- **根因**: `lipsync_service.py` 中 `httpx.AsyncClient(timeout=1200.0)` 不足以覆盖长视频推理时间
|
||||||
|
- **修复**: 超时从 `1200s`(20 分钟)改为 `3600s`(1 小时),足以覆盖 2-3 分钟视频的推理
|
||||||
|
|
||||||
|
### 24. 字幕时间戳节奏映射(修复长视频字幕漂移)
|
||||||
|
|
||||||
|
- **问题**: 2 分钟视频字幕明显对不上语音,越到后面偏差越大
|
||||||
|
- **根因**: `whisper_service.py` 的 `original_text` 处理逻辑丢弃了 Whisper 逐词时间戳,仅保留总时间范围后做全程线性插值,每个字分配相同时长,完全忽略语速变化和停顿
|
||||||
|
- **修复**: 保留 Whisper 的逐字时间戳作为语音节奏模板,将原文字符按比例映射到 Whisper 时间节奏上(rhythm-mapping),而非线性均分。字幕文字不变,只是时间戳跟随真实语速
|
||||||
|
- **算法**: 原文第 i 个字符映射到 Whisper 时间线的 `(i/N)*M` 位置(N=原文字符数,M=Whisper字符数),在相邻 Whisper 时间点间线性插值
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📁 修改文件清单
|
||||||
|
|
||||||
|
| 文件 | 改动 |
|
||||||
|
|------|------|
|
||||||
|
| `VoiceSelector.tsx` | 新增 embedded prop,移动端按钮适配(`px-2 sm:px-4`) |
|
||||||
|
| `GeneratedAudiosPanel.tsx` | 新增 embedded prop,两行布局,操作按钮可见度,"生成配音"按钮放大 |
|
||||||
|
| `MaterialSelector.tsx` | 新增 embedded prop,自渲染子标题+操作按钮,useMemo,disabled 守卫,操作按钮可见度,标题溢出修复 |
|
||||||
|
| `TimelineEditor.tsx` | 新增 embedded prop,自渲染子标题+控件,useMemo,拖拽抓手图标 |
|
||||||
|
| `PreviewPanel.tsx` | 新增 embedded prop |
|
||||||
|
| `HistoryList.tsx` | 新增 embedded prop,操作按钮可见度 |
|
||||||
|
| `ScriptEditor.tsx` | 标题加序号,移除 emoji,操作按钮可见度 |
|
||||||
|
| `TitleSubtitlePanel.tsx` | 标题加序号,移除 emoji,下拉对齐,显示模式下拉上移,字数计数器 |
|
||||||
|
| `BgmPanel.tsx` | 标题加序号 |
|
||||||
|
| `HomePage.tsx` | 核心重构:合并板块、序号标题、生成配音按钮迁入、`scrollRestoration` + 延迟兜底修复刷新回顶部、生成进度条提取到作品卡片上方 |
|
||||||
|
| `PublishPage.tsx` | 四个板块加序号(七~十),移除 emoji,平台卡片响应式单行布局 |
|
||||||
|
| `RefAudioPanel.tsx` | 简化提示文案,操作按钮可见度 |
|
||||||
|
| `AccountSettingsDropdown.tsx` | 新增手机号显示(脱敏),补齐 success 清空 |
|
||||||
|
| `AuthContext.tsx` | 新增 `setUser` 方法,登录后立即更新用户状态 |
|
||||||
|
| `login/page.tsx` | 登录成功后调用 `setUser` 写入用户数据 |
|
||||||
|
| `useHomeController.ts` | titleDisplayMode 条件修复,列表 scroll 时间门控 `scrollEffectsEnabled` |
|
||||||
|
| `FloatingStylePreview.tsx` | 移动端预览窗口缩小(160px)并移至右下角 |
|
||||||
|
| `ScriptExtractionModal.tsx` | 滚动条改回隐藏 |
|
||||||
|
| `ClipTrimmer.tsx` | 滑块手柄放大、触控区增高 |
|
||||||
|
| `lipsync_service.py` | httpx 超时从 1200s 改为 3600s |
|
||||||
|
| `whisper_service.py` | 字幕时间戳从线性插值改为 Whisper 节奏映射 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔍 验证
|
||||||
|
|
||||||
|
- `npm run build` — 零报错零警告
|
||||||
|
- 合并后布局:各子板块分隔清晰、主标题有序号
|
||||||
|
- 向后兼容:`embedded` 默认 `false`,组件独立使用不受影响
|
||||||
|
- 配音列表两行布局:语速+生成配音在上,配音列表+刷新在下
|
||||||
|
- 下拉菜单垂直对齐正确
|
||||||
|
- 短暂显示/常驻显示对标题和副标题同时生效
|
||||||
|
- 操作按钮在移动端(触屏)可见
|
||||||
|
- 手机号脱敏显示
|
||||||
|
- 标题字数计数器正常
|
||||||
|
- 列表滚动条全部隐藏
|
||||||
|
- 时间轴拖拽抓手图标显示
|
||||||
|
- 发布页平台卡片:移动端紧凑、桌面端宽松,风格一致
|
||||||
|
- 移动端刷新后回到顶部,不再滚动到背景音乐位置
|
||||||
|
- 移动端样式预览窗口不遮挡控件
|
||||||
|
- 移动端配音按钮(选择声音/克隆声音)均可见
|
||||||
|
- 移动端素材标题行按钮不溢出
|
||||||
|
- 生成配音按钮视觉层级高于辅助按钮
|
||||||
|
- 生成进度条在作品卡片上方独立显示
|
||||||
|
- LatentSync 长视频推理不再超时回退
|
||||||
|
- 字幕时间戳与语音节奏同步,长视频不漂移
|
||||||
@@ -196,6 +196,7 @@ ViGent2/Docs/
|
|||||||
├── SUPABASE_DEPLOY.md # Supabase 部署文档
|
├── SUPABASE_DEPLOY.md # Supabase 部署文档
|
||||||
├── LATENTSYNC_DEPLOY.md # LatentSync 部署文档
|
├── LATENTSYNC_DEPLOY.md # LatentSync 部署文档
|
||||||
├── COSYVOICE3_DEPLOY.md # 声音克隆部署文档
|
├── COSYVOICE3_DEPLOY.md # 声音克隆部署文档
|
||||||
|
├── ALIPAY_DEPLOY.md # 支付宝付费部署文档
|
||||||
├── SUBTITLE_DEPLOY.md # 字幕系统部署文档
|
├── SUBTITLE_DEPLOY.md # 字幕系统部署文档
|
||||||
└── DevLogs/
|
└── DevLogs/
|
||||||
├── Day1.md # 开发日志
|
├── Day1.md # 开发日志
|
||||||
@@ -304,4 +305,4 @@ ViGent2/Docs/
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**最后更新**:2026-02-08
|
**最后更新**:2026-02-11
|
||||||
|
|||||||
@@ -10,8 +10,9 @@ frontend/src/
|
|||||||
│ ├── page.tsx # 首页(视频生成)
|
│ ├── page.tsx # 首页(视频生成)
|
||||||
│ ├── publish/ # 发布管理页
|
│ ├── publish/ # 发布管理页
|
||||||
│ ├── admin/ # 管理员页面
|
│ ├── admin/ # 管理员页面
|
||||||
│ ├── login/ # 登录
|
│ ├── login/ # 登录
|
||||||
│ └── register/ # 注册
|
│ ├── register/ # 注册
|
||||||
|
│ └── pay/ # 付费开通会员
|
||||||
├── features/ # 功能模块(按业务拆分)
|
├── features/ # 功能模块(按业务拆分)
|
||||||
│ ├── home/
|
│ ├── home/
|
||||||
│ │ ├── model/ # 业务逻辑 hooks
|
│ │ ├── model/ # 业务逻辑 hooks
|
||||||
@@ -150,6 +151,33 @@ body {
|
|||||||
| `sm:` | ≥ 640px | 平板/桌面 |
|
| `sm:` | ≥ 640px | 平板/桌面 |
|
||||||
| `lg:` | ≥ 1024px | 大屏桌面 |
|
| `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` | 列表行内操作(编辑/删除) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## API 请求规范
|
## API 请求规范
|
||||||
@@ -256,6 +284,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);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 列表滚动 effect(BGM/素材/视频等)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedId || !scrollEffectsEnabled.current) return;
|
||||||
|
target?.scrollIntoView({ block: "nearest", behavior: "smooth" });
|
||||||
|
}, [selectedId, list]);
|
||||||
|
```
|
||||||
|
|
||||||
### 路由预取
|
### 路由预取
|
||||||
|
|
||||||
- 首页进入发布管理时使用 `router.prefetch("/publish")`
|
- 首页进入发布管理时使用 `router.prefetch("/publish")`
|
||||||
@@ -305,7 +365,9 @@ import { formatDate } from '@/shared/lib/media';
|
|||||||
- **必须持久化**:
|
- **必须持久化**:
|
||||||
- 标题样式 ID / 字幕样式 ID
|
- 标题样式 ID / 字幕样式 ID
|
||||||
- 标题字号 / 字幕字号
|
- 标题字号 / 字幕字号
|
||||||
|
- 标题显示模式(`short` / `persistent`)
|
||||||
- 背景音乐选择 / 音量 / 开关状态
|
- 背景音乐选择 / 音量 / 开关状态
|
||||||
|
- 输出画面比例(`9:16` / `16:9`)
|
||||||
- 素材选择 / 历史作品选择
|
- 素材选择 / 历史作品选择
|
||||||
- 选中配音 ID (`selectedAudioId`)
|
- 选中配音 ID (`selectedAudioId`)
|
||||||
- 语速 (`speed`,声音克隆模式)
|
- 语速 (`speed`,声音克隆模式)
|
||||||
@@ -333,6 +395,7 @@ import { formatDate } from '@/shared/lib/media';
|
|||||||
- 片头标题与发布信息标题统一限制 15 字。
|
- 片头标题与发布信息标题统一限制 15 字。
|
||||||
- 中文输入法合成阶段不截断,合成结束后才校验长度。
|
- 中文输入法合成阶段不截断,合成结束后才校验长度。
|
||||||
- 首页片头标题修改会同步写入 `vigent_${storageKey}_publish_title`。
|
- 首页片头标题修改会同步写入 `vigent_${storageKey}_publish_title`。
|
||||||
|
- 标题显示模式使用 `short` / `persistent` 两个固定值;默认 `short`(短暂显示 4 秒)。
|
||||||
- 避免使用 `maxLength` 强制截断输入法合成态。
|
- 避免使用 `maxLength` 强制截断输入法合成态。
|
||||||
- 推荐使用 `@/shared/hooks/useTitleInput` 统一处理输入逻辑。
|
- 推荐使用 `@/shared/hooks/useTitleInput` 统一处理输入逻辑。
|
||||||
|
|
||||||
|
|||||||
@@ -5,14 +5,12 @@ ViGent2 的前端界面,采用 Next.js 16 + TailwindCSS 构建。
|
|||||||
## ✨ 核心功能
|
## ✨ 核心功能
|
||||||
|
|
||||||
### 1. 视频生成 (`/`)
|
### 1. 视频生成 (`/`)
|
||||||
- **素材管理**: 拖拽上传人物视频,实时预览。
|
- **一、文案提取与编辑**: 文案输入/提取/翻译/保存。
|
||||||
- **素材重命名**: 支持在列表中直接重命名素材。
|
- **二、标题与字幕**: 片头标题/副标题/字幕样式配置;短暂显示/常驻显示对标题和副标题同时生效。
|
||||||
- **文案配音**: 集成 EdgeTTS,支持多音色选择 (云溪 / 晓晓)。
|
- **三、配音**: 配音方式(EdgeTTS/声音克隆)+ 配音列表(生成/试听/管理)合并为一个板块。
|
||||||
- **AI 标题/标签**: 一键生成视频标题与标签 (Day 14)。
|
- **四、素材编辑**: 视频素材(上传/选择/管理)+ 时间轴编辑(波形/色块/拖拽排序)合并为一个板块。
|
||||||
- **标题/字幕样式**: 样式选择 + 预览 + 字号调节 (Day 16)。
|
- **五、背景音乐**: 试听 + 音量控制 + 选择持久化。
|
||||||
- **背景音乐**: 试听 + 音量控制 + 选择持久化 (Day 16)。
|
- **六、作品**(右栏): 作品列表 + 作品预览合并为一个板块。
|
||||||
- **交互优化**: 选择项持久化、列表内定位、刷新回顶部 (Day 16)。
|
|
||||||
- **预览一致性**: 标题/字幕预览按素材分辨率缩放,效果更接近成片 (Day 17)。
|
|
||||||
- **进度追踪**: 实时显示视频生成进度 (10% -> 100%)。
|
- **进度追踪**: 实时显示视频生成进度 (10% -> 100%)。
|
||||||
- **作品预览**: 生成完成后直接播放下载(作品预览 + 历史作品)。
|
- **作品预览**: 生成完成后直接播放下载(作品预览 + 历史作品)。
|
||||||
- **预览优化**: 预览视频 `metadata` 预取,首帧加载更快。
|
- **预览优化**: 预览视频 `metadata` 预取,首帧加载更快。
|
||||||
@@ -52,13 +50,14 @@ ViGent2 的前端界面,采用 Next.js 16 + TailwindCSS 构建。
|
|||||||
- **画面比例控制**: 时间轴顶部支持 `9:16 / 16:9` 输出比例选择,设置持久化并透传后端。
|
- **画面比例控制**: 时间轴顶部支持 `9:16 / 16:9` 输出比例选择,设置持久化并透传后端。
|
||||||
|
|
||||||
### 5. 字幕与标题 [Day 13 新增]
|
### 5. 字幕与标题 [Day 13 新增]
|
||||||
- **片头标题**: 可选输入,限制 15 字,视频开头显示 3 秒淡入淡出标题。
|
- **片头标题**: 可选输入,限制 15 字;支持”短暂显示 / 常驻显示”,默认短暂显示(4 秒),对标题和副标题同时生效。
|
||||||
|
- **片头副标题**: 可选输入,限制 20 字;显示在主标题下方,用于补充说明或悬念引导;独立样式配置(字体/字号/颜色/间距),可由 AI 同时生成;与标题共享显示模式设定;仅在视频画面中显示,不参与发布标题 (Day 25)。
|
||||||
- **标题同步**: 首页片头标题修改会同步到发布信息标题。
|
- **标题同步**: 首页片头标题修改会同步到发布信息标题。
|
||||||
- **逐字高亮字幕**: 卡拉OK效果,默认开启,可关闭。
|
- **逐字高亮字幕**: 卡拉OK效果,默认开启,可关闭。
|
||||||
- **自动对齐**: 基于 faster-whisper 生成字级别时间戳。
|
- **自动对齐**: 基于 faster-whisper 生成字级别时间戳。
|
||||||
- **样式预设**: 标题/字幕样式选择 + 预览 + 字号调节 (Day 16)。
|
- **样式预设**: 标题/字幕/副标题样式选择 + 预览 + 字号调节 (Day 16/25)。
|
||||||
- **默认样式**: 标题 90px 站酷快乐体;字幕 60px 经典黄字 + DingTalkJinBuTi (Day 17)。
|
- **默认样式**: 标题 90px 站酷快乐体;字幕 60px 经典黄字 + DingTalkJinBuTi (Day 17)。
|
||||||
- **样式持久化**: 标题/字幕样式与字号刷新保留 (Day 17)。
|
- **样式持久化**: 标题/字幕/副标题样式与字号刷新保留 (Day 17/25)。
|
||||||
|
|
||||||
### 6. 背景音乐 [Day 16 新增]
|
### 6. 背景音乐 [Day 16 新增]
|
||||||
- **试听预览**: 点击试听即选中,音量滑块实时生效。
|
- **试听预览**: 点击试听即选中,音量滑块实时生效。
|
||||||
@@ -66,12 +65,20 @@ ViGent2 的前端界面,采用 Next.js 16 + TailwindCSS 构建。
|
|||||||
|
|
||||||
### 7. 账户设置 [Day 15 新增]
|
### 7. 账户设置 [Day 15 新增]
|
||||||
- **手机号登录**: 11位中国手机号验证登录。
|
- **手机号登录**: 11位中国手机号验证登录。
|
||||||
- **账户下拉菜单**: 显示有效期 + 修改密码 + 安全退出。
|
- **账户下拉菜单**: 显示手机号(中间四位脱敏)+ 有效期 + 修改密码 + 安全退出。
|
||||||
- **修改密码**: 弹窗输入当前密码与新密码,修改后强制重新登录。
|
- **修改密码**: 弹窗输入当前密码与新密码,修改后强制重新登录。
|
||||||
|
- **登录即时生效**: 登录成功后 AuthContext 立即写入用户数据,无需刷新即显示手机号。
|
||||||
|
|
||||||
|
### 8. 付费开通会员 (`/pay`)
|
||||||
|
- **支付宝电脑网站支付**: 跳转支付宝官方收银台,支持扫码/账号登录/余额等多种支付方式。
|
||||||
|
- **自动激活**: 支付成功后异步回调自动激活会员(有效期 1 年),前端轮询检测支付结果。
|
||||||
|
- **到期续费**: 会员到期后登录自动跳转付费页续费,流程与首次开通一致。
|
||||||
|
- **管理员激活**: 管理员手动激活功能并存,两种方式互不影响。
|
||||||
|
|
||||||
### 8. 文案提取助手 (`ScriptExtractionModal`) [Day 15 新增]
|
### 8. 文案提取助手 (`ScriptExtractionModal`) [Day 15 新增]
|
||||||
- **多源提取**: 支持文件拖拽上传与 URL 粘贴 (B站/抖音/TikTok)。
|
- **多源提取**: 支持文件拖拽上传与 URL 粘贴 (B站/抖音/TikTok)。
|
||||||
- **AI 洗稿**: 集成 GLM-4.7-Flash,自动改写为口播文案。
|
- **AI 智能改写**: 集成 GLM-4.7-Flash,自动改写为口播文案。
|
||||||
|
- **自定义提示词**: 可自定义改写提示词,留空使用默认;设置持久化到 localStorage (Day 25)。
|
||||||
- **一键填入**: 提取结果直接填充至视频生成输入框。
|
- **一键填入**: 提取结果直接填充至视频生成输入框。
|
||||||
- **智能交互**: 实时进度展示,防误触设计。
|
- **智能交互**: 实时进度展示,防误触设计。
|
||||||
|
|
||||||
@@ -109,6 +116,8 @@ src/
|
|||||||
│ ├── page.tsx # 视频生成主页
|
│ ├── page.tsx # 视频生成主页
|
||||||
│ ├── publish/ # 发布管理页
|
│ ├── publish/ # 发布管理页
|
||||||
│ │ └── page.tsx
|
│ │ └── page.tsx
|
||||||
|
│ ├── pay/ # 付费开通会员页
|
||||||
|
│ │ └── page.tsx
|
||||||
│ └── layout.tsx # 全局布局 (导航栏)
|
│ └── layout.tsx # 全局布局 (导航栏)
|
||||||
├── features/
|
├── features/
|
||||||
│ ├── home/
|
│ ├── home/
|
||||||
@@ -133,5 +142,8 @@ src/
|
|||||||
## 🎨 设计规范
|
## 🎨 设计规范
|
||||||
|
|
||||||
- **主色调**: 深紫/黑色系 (Dark Mode)
|
- **主色调**: 深紫/黑色系 (Dark Mode)
|
||||||
- **交互**: 悬停微动画 (Hover Effects)
|
- **交互**: 悬停微动画 (Hover Effects);操作按钮默认半透明可见 (opacity-40),hover 时全亮,兼顾触屏设备
|
||||||
- **响应式**: 适配桌面端大屏操作
|
- **响应式**: 适配桌面端与移动端;发布页平台卡片响应式布局(移动端紧凑/桌面端宽松)
|
||||||
|
- **滚动体验**: 列表滚动条统一隐藏 (hide-scrollbar);刷新后自动回到顶部(禁用浏览器滚动恢复 + 列表 scroll 时间门控)
|
||||||
|
- **样式预览**: 浮动预览窗口,桌面端左上角 280px,移动端右下角 160px(不遮挡控件)
|
||||||
|
- **输入辅助**: 标题/副标题输入框实时字数计数器,超限变红
|
||||||
|
|||||||
@@ -185,7 +185,8 @@ Remotion 渲染参数在 `backend/app/services/remotion_service.py` 中配置:
|
|||||||
| 参数 | 默认值 | 说明 |
|
| 参数 | 默认值 | 说明 |
|
||||||
|------|--------|------|
|
|------|--------|------|
|
||||||
| `fps` | 25 | 输出帧率 |
|
| `fps` | 25 | 输出帧率 |
|
||||||
| `title_duration` | 3.0 | 标题显示时长(秒) |
|
| `title_display_mode` | `short` | 标题显示模式(`short`=短暂显示;`persistent`=常驻显示) |
|
||||||
|
| `title_duration` | 4.0 | 标题显示时长(秒,仅 `short` 模式生效) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -288,3 +289,4 @@ WhisperService(device="cuda:0") # 或 "cuda:1"
|
|||||||
| 2026-01-29 | 1.0.0 | 初始版本,使用 faster-whisper + Remotion 实现逐字高亮字幕和片头标题 |
|
| 2026-01-29 | 1.0.0 | 初始版本,使用 faster-whisper + Remotion 实现逐字高亮字幕和片头标题 |
|
||||||
| 2026-02-10 | 1.1.0 | 更新架构图:多素材 concat-then-infer、预生成配音选项 |
|
| 2026-02-10 | 1.1.0 | 更新架构图:多素材 concat-then-infer、预生成配音选项 |
|
||||||
| 2026-01-30 | 1.0.1 | 字幕高亮样式与标题动画优化,视觉表现更清晰 |
|
| 2026-01-30 | 1.0.1 | 字幕高亮样式与标题动画优化,视觉表现更清晰 |
|
||||||
|
| 2026-02-25 | 1.2.0 | 字幕时间戳从线性插值改为 Whisper 节奏映射,修复长视频字幕漂移 |
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
# ViGent2 开发任务清单 (Task Log)
|
# ViGent2 开发任务清单 (Task Log)
|
||||||
|
|
||||||
**项目**: ViGent2 数字人口播视频生成系统
|
**项目**: ViGent2 数字人口播视频生成系统
|
||||||
**进度**: 100% (Day 24 - 鉴权到期治理 + 多素材时间轴稳定性修复)
|
**进度**: 100% (Day 26 - 前端优化:板块合并 + 序号标题)
|
||||||
**更新时间**: 2026-02-11
|
**更新时间**: 2026-02-25
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -10,16 +10,51 @@
|
|||||||
|
|
||||||
> 这里记录了每一天的核心开发内容与 milestone。
|
> 这里记录了每一天的核心开发内容与 milestone。
|
||||||
|
|
||||||
### Day 24: 鉴权到期治理 + 多素材时间轴稳定性修复 (Current)
|
### Day 26: 前端优化:板块合并 + 序号标题 + UI 精细化 (Current)
|
||||||
- [x] **会员到期请求时失效**: 登录与鉴权接口统一执行 `expires_at` 检查;到期后自动停用账号、清理 session,并返回“会员已到期,请续费”。
|
- [x] **板块合并**: 首页 9 个独立板块合并为 5 个主板块(配音方式+配音列表→三、配音;视频素材+时间轴→四、素材编辑;历史作品+作品预览→六、作品)。
|
||||||
- [x] **画面比例控制**: 时间轴新增 `9:16 / 16:9` 输出比例选择,前端持久化并透传后端,单素材/多素材统一按目标分辨率处理。
|
- [x] **中文序号标题**: 一~十编号(首页一~六,发布页七~十),移除所有 emoji 图标。
|
||||||
- [x] **标题/字幕防溢出**: Remotion 与前端预览统一响应式缩放、自动换行、描边/字距/边距比例缩放,降低预览与成片差异。
|
- [x] **embedded 模式**: 6 个组件支持 `embedded` prop,嵌入时不渲染外层卡片/标题。
|
||||||
- [x] **MOV 方向归一化**: 新增旋转元数据解析与 orientation normalize,修复“编码横屏+旋转元数据”导致的竖屏判断偏差。
|
- [x] **配音列表两行布局**: embedded 模式第 1 行语速+生成配音(右对齐),第 2 行配音列表+刷新。
|
||||||
- [x] **多素材拼接稳定性**: 片段 prepare 与 concat 统一 25fps/CFR,concat 增加 `+genpts`,缓解段切换处“画面冻结口型还动”。
|
- [x] **子组件自渲染子标题**: MaterialSelector/TimelineEditor embedded 时自渲染 h3 子标题+操作按钮同行。
|
||||||
- [x] **时间轴语义对齐**: 打通 `source_end` 全链路;修复 `sourceStart>0 且 sourceEnd=0` 时长计算;生成时以时间轴可见段 assignments 为准,超出段不参与。
|
- [x] **下拉对齐**: TitleSubtitlePanel 标签统一 `w-20`,下拉 `w-1/3 min-w-[100px]`,垂直对齐。
|
||||||
- [x] **交互细节优化**: 页面刷新回顶部;素材/历史列表首轮自动滚动抑制,减少恢复状态时页面跳动。
|
- [x] **参考音频文案简化**: 底部段落移至标题旁,简化为 `(上传3-10秒语音样本)`。
|
||||||
|
- [x] **账户手机号显示**: AccountSettingsDropdown 新增手机号显示。
|
||||||
### Day 23: 配音前置重构 + 素材时间轴编排 + UI 体验优化 + 声音克隆增强
|
- [x] **标题显示模式对副标题生效**: payload 条件修复 + UI 下拉上移至板块标题行。
|
||||||
|
- [x] **登录后用户信息立即可用**: AuthContext 暴露 `setUser`,登录成功后立即写入用户数据,修复登录后显示"未知账户"的问题。
|
||||||
|
- [x] **文案微调**: 素材描述改为"上传自拍视频,最多可选4个";显示模式选项加"标题"前缀。
|
||||||
|
- [x] **UI/UX 体验优化**: 操作按钮移动端可见(opacity-40)、手机号脱敏、标题字数计数器、时间轴拖拽抓手图标、截取滑块放大。
|
||||||
|
- [x] **代码质量修复**: 密码弹窗 success 清空、MaterialSelector useMemo + disabled 守卫、TimelineEditor useMemo。
|
||||||
|
- [x] **发布页响应式布局**: 平台账号卡片单行布局,移动端紧凑(小图标/小按钮),桌面端宽松(与其他板块风格一致)。
|
||||||
|
- [x] **移动端刷新回顶部**: `scrollRestoration = "manual"` + 列表 scroll 时间门控(`scrollEffectsEnabled` ref,1 秒内禁止自动滚动)+ 延迟兜底 `scrollTo(0,0)`。
|
||||||
|
- [x] **移动端样式预览缩小**: FloatingStylePreview 移动端宽度缩至 160px,位置改为右下角,不遮挡样式调节控件。
|
||||||
|
- [x] **列表滚动条统一隐藏**: 所有列表(BGM/配音/作品/素材/文案提取)滚动条改回 `hide-scrollbar`。
|
||||||
|
- [x] **移动端配音/素材适配**: VoiceSelector 按钮移动端缩小(`px-2 sm:px-4`)修复克隆声音不可见;MaterialSelector 标题行移除 `whitespace-nowrap`,描述移动端隐藏,修复刷新按钮溢出。
|
||||||
|
- [x] **生成配音按钮放大**: 从辅助尺寸(`text-xs px-2 py-1`)升级为主操作尺寸(`text-sm font-medium px-4 py-2`),新增阴影。
|
||||||
|
- [x] **生成进度条位置调整**: 从"六、作品"卡片内部提取到右栏独立卡片,显示在作品卡片上方,更醒目。
|
||||||
|
- [x] **LatentSync 超时修复**: httpx 超时从 1200s(20 分钟)改为 3600s(1 小时),修复 2 分钟以上视频口型推理超时回退问题。
|
||||||
|
- [x] **字幕时间戳节奏映射**: `whisper_service.py` 从全程线性插值改为 Whisper 逐词节奏映射,修复长视频字幕漂移。
|
||||||
|
|
||||||
|
### Day 25: 文案提取修复 + 自定义提示词 + 片头副标题
|
||||||
|
- [x] **抖音文案提取修复**: yt-dlp Fresh cookies 报错,重写 `_download_douyin_manual` 为移动端分享页 + 自动获取 ttwid 方案。
|
||||||
|
- [x] **清理 DOUYIN_COOKIE**: 新方案不再需要手动维护 Cookie,从 `.env`/`config.py`/`service.py` 全面删除。
|
||||||
|
- [x] **AI 智能改写自定义提示词**: 后端 `rewrite_script()` 支持 `custom_prompt` 参数;前端 checkbox 旁新增折叠式提示词编辑区,localStorage 持久化。
|
||||||
|
- [x] **SSR 构建修复**: `useState` 初始化 `localStorage` 访问加 `typeof window` 守卫,修复 `npm run build` 报错。
|
||||||
|
- [x] **片头副标题**: 新增 secondary_title(后端/Remotion/前端全链路),AI 同时生成,独立样式配置,20 字限制。
|
||||||
|
- [x] **前端文案修正**: "AI 洗稿结果"→"AI 改写结果"。
|
||||||
|
- [x] **yt-dlp 升级**: `2025.12.08` → `2026.2.21`。
|
||||||
|
- [x] **参考音频中文文件名修复**: `sanitize_filename()` 将存储路径清洗为 ASCII 安全字符,纯中文名哈希兜底,原始名保留为展示名。
|
||||||
|
|
||||||
|
### Day 24: 鉴权到期治理 + 多素材时间轴稳定性修复
|
||||||
|
- [x] **会员到期请求时失效**: 登录与鉴权接口统一执行 `expires_at` 检查;到期后自动停用账号、清理 session,并返回“会员已到期,请续费”。
|
||||||
|
- [x] **画面比例控制**: 时间轴新增 `9:16 / 16:9` 输出比例选择,前端持久化并透传后端,单素材/多素材统一按目标分辨率处理。
|
||||||
|
- [x] **标题/字幕防溢出**: Remotion 与前端预览统一响应式缩放、自动换行、描边/字距/边距比例缩放,降低预览与成片差异。
|
||||||
|
- [x] **标题显示模式**: 标题行新增“短暂显示/常驻显示”下拉;默认短暂显示(4 秒),用户选择持久化并透传至 Remotion 渲染链路。
|
||||||
|
- [x] **MOV 方向归一化**: 新增旋转元数据解析与 orientation normalize,修复“编码横屏+旋转元数据”导致的竖屏判断偏差。
|
||||||
|
- [x] **多素材拼接稳定性**: 片段 prepare 与 concat 统一 25fps/CFR,concat 增加 `+genpts`,缓解段切换处“画面冻结口型还动”。
|
||||||
|
- [x] **时间轴语义对齐**: 打通 `source_end` 全链路;修复 `sourceStart>0 且 sourceEnd=0` 时长计算;生成时以时间轴可见段 assignments 为准,超出段不参与。
|
||||||
|
- [x] **交互细节优化**: 页面刷新回顶部;素材/历史列表首轮自动滚动抑制,减少恢复状态时页面跳动。
|
||||||
|
|
||||||
|
### Day 23: 配音前置重构 + 素材时间轴编排 + UI 体验优化 + 声音克隆增强
|
||||||
|
|
||||||
#### 第一阶段:配音前置
|
#### 第一阶段:配音前置
|
||||||
- [x] **配音生成独立化**: 新增 `generated_audios` 后端模块(router/schemas/service),5 个 API 端点,复用现有 TTSService / voice_clone_service / task_store。
|
- [x] **配音生成独立化**: 新增 `generated_audios` 后端模块(router/schemas/service),5 个 API 端点,复用现有 TTSService / voice_clone_service / task_store。
|
||||||
@@ -212,6 +247,7 @@
|
|||||||
| **TTS 配音** | 100% | ✅ EdgeTTS + CosyVoice 3.0 + 配音前置 + 时间轴编排 + 自动转写 + 语速控制 |
|
| **TTS 配音** | 100% | ✅ EdgeTTS + CosyVoice 3.0 + 配音前置 + 时间轴编排 + 自动转写 + 语速控制 |
|
||||||
| **自动发布** | 100% | ✅ 抖音/微信视频号/B站/小红书 |
|
| **自动发布** | 100% | ✅ 抖音/微信视频号/B站/小红书 |
|
||||||
| **用户认证** | 100% | ✅ 手机号 + JWT |
|
| **用户认证** | 100% | ✅ 手机号 + JWT |
|
||||||
|
| **付费会员** | 100% | ✅ 支付宝电脑网站支付 + 自动激活 |
|
||||||
| **部署运维** | 100% | ✅ PM2 + Watchdog |
|
| **部署运维** | 100% | ✅ PM2 + Watchdog |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
14
README.md
14
README.md
@@ -19,19 +19,22 @@
|
|||||||
- 🎬 **高清唇形同步** - LatentSync 1.6 驱动,512×512 高分辨率 Latent Diffusion 模型。
|
- 🎬 **高清唇形同步** - LatentSync 1.6 驱动,512×512 高分辨率 Latent Diffusion 模型。
|
||||||
- 🎙️ **多模态配音** - 支持 **EdgeTTS** (微软超自然语音, 10 语言) 和 **CosyVoice 3.0** (3秒极速声音克隆, 9语言+18方言, 语速可调)。上传参考音频自动 Whisper 转写 + 智能截取。配音前置工作流:先生成配音 → 选素材 → 生成视频。
|
- 🎙️ **多模态配音** - 支持 **EdgeTTS** (微软超自然语音, 10 语言) 和 **CosyVoice 3.0** (3秒极速声音克隆, 9语言+18方言, 语速可调)。上传参考音频自动 Whisper 转写 + 智能截取。配音前置工作流:先生成配音 → 选素材 → 生成视频。
|
||||||
- 📝 **智能字幕** - 集成 faster-whisper + Remotion,自动生成逐字高亮 (卡拉OK效果) 字幕。
|
- 📝 **智能字幕** - 集成 faster-whisper + Remotion,自动生成逐字高亮 (卡拉OK效果) 字幕。
|
||||||
- 🎨 **样式预设** - 标题/字幕样式选择 + 预览 + 字号调节,支持自定义字体库。
|
- 🎨 **样式预设** - 标题/副标题/字幕样式选择 + 预览 + 字号调节,支持自定义字体库。
|
||||||
- 🖼️ **作品预览一致性** - 标题/字幕预览与 Remotion 成片统一响应式缩放和自动换行,窄屏画布也稳定显示。
|
- 🏷️ **标题显示模式** - 片头标题支持 `短暂显示` / `常驻显示`,默认短暂显示(4秒),用户偏好自动持久化。
|
||||||
- 🎞️ **多素材多机位** - 支持多选素材 + 时间轴编辑器 (wavesurfer.js 波形可视化),拖拽分割线调整时长、拖拽排序切换机位、按 `source_start/source_end` 截取片段。
|
- 📌 **片头副标题** - 可选副标题显示在主标题下方,独立样式配置,AI 可同时生成,20 字限制。
|
||||||
- 📐 **画面比例控制** - 时间轴一键切换 `9:16 / 16:9` 输出比例,生成链路全程按目标比例处理。
|
- 🖼️ **作品预览一致性** - 标题/字幕预览与 Remotion 成片统一响应式缩放和自动换行,窄屏画布也稳定显示。
|
||||||
|
- 🎞️ **多素材多机位** - 支持多选素材 + 时间轴编辑器 (wavesurfer.js 波形可视化),拖拽分割线调整时长、拖拽排序切换机位、按 `source_start/source_end` 截取片段。
|
||||||
|
- 📐 **画面比例控制** - 时间轴一键切换 `9:16 / 16:9` 输出比例,生成链路全程按目标比例处理。
|
||||||
- 💾 **用户偏好持久化** - 首页状态统一恢复/保存,刷新后延续上次配置。历史文案手动保存与加载。
|
- 💾 **用户偏好持久化** - 首页状态统一恢复/保存,刷新后延续上次配置。历史文案手动保存与加载。
|
||||||
- 🎵 **背景音乐** - 试听 + 音量控制 + 混音,保持配音音量稳定。
|
- 🎵 **背景音乐** - 试听 + 音量控制 + 混音,保持配音音量稳定。
|
||||||
- 🤖 **AI 辅助创作** - 内置 GLM-4.7-Flash,支持 B站/抖音链接文案提取、AI 洗稿、标题/标签自动生成、9 语言翻译。
|
- 🤖 **AI 辅助创作** - 内置 GLM-4.7-Flash,支持 B站/抖音链接文案提取、AI 智能改写(支持自定义提示词)、标题/标签自动生成、9 语言翻译。
|
||||||
|
|
||||||
### 平台化功能
|
### 平台化功能
|
||||||
- 📱 **全自动发布** - 支持抖音/微信视频号/B站/小红书立即发布;扫码登录 + Cookie 持久化。
|
- 📱 **全自动发布** - 支持抖音/微信视频号/B站/小红书立即发布;扫码登录 + Cookie 持久化。
|
||||||
- 🖥️ **发布管理预览** - 支持签名 URL / 相对路径作品预览,确保可直接播放。
|
- 🖥️ **发布管理预览** - 支持签名 URL / 相对路径作品预览,确保可直接播放。
|
||||||
- 📸 **发布结果可视化** - 抖音/微信视频号发布成功后返回截图,发布页结果卡片可直接查看。
|
- 📸 **发布结果可视化** - 抖音/微信视频号发布成功后返回截图,发布页结果卡片可直接查看。
|
||||||
- 🛡️ **发布防误操作** - 发布进行中自动提示“请勿刷新或关闭网页”,并拦截刷新/关页二次确认。
|
- 🛡️ **发布防误操作** - 发布进行中自动提示“请勿刷新或关闭网页”,并拦截刷新/关页二次确认。
|
||||||
|
- 💳 **付费会员** - 支付宝电脑网站支付自动开通会员,到期自动停用并引导续费,管理员手动激活并存。
|
||||||
- 🔐 **认证与隔离** - 基于 Supabase 的用户隔离,支持手机号注册/登录、密码管理。
|
- 🔐 **认证与隔离** - 基于 Supabase 的用户隔离,支持手机号注册/登录、密码管理。
|
||||||
- 🛡️ **服务守护** - 内置 Watchdog 看门狗机制,自动监控并重启僵死服务,确保 7x24h 稳定运行。
|
- 🛡️ **服务守护** - 内置 Watchdog 看门狗机制,自动监控并重启僵死服务,确保 7x24h 稳定运行。
|
||||||
- 🚀 **性能优化** - 视频预压缩、模型常驻服务(近实时加载)、双 GPU 流水线并发。
|
- 🚀 **性能优化** - 视频预压缩、模型常驻服务(近实时加载)、双 GPU 流水线并发。
|
||||||
@@ -61,6 +64,7 @@
|
|||||||
- [参考音频服务部署 (COSYVOICE3_DEPLOY.md)](Docs/COSYVOICE3_DEPLOY.md) - 声音克隆模型部署指南。
|
- [参考音频服务部署 (COSYVOICE3_DEPLOY.md)](Docs/COSYVOICE3_DEPLOY.md) - 声音克隆模型部署指南。
|
||||||
- [LatentSync 部署指南](models/LatentSync/DEPLOY.md) - 唇形同步模型独立部署。
|
- [LatentSync 部署指南](models/LatentSync/DEPLOY.md) - 唇形同步模型独立部署。
|
||||||
- [Supabase 部署指南 (SUPABASE_DEPLOY.md)](Docs/SUPABASE_DEPLOY.md) - Supabase 与认证系统配置。
|
- [Supabase 部署指南 (SUPABASE_DEPLOY.md)](Docs/SUPABASE_DEPLOY.md) - Supabase 与认证系统配置。
|
||||||
|
- [支付宝部署指南 (ALIPAY_DEPLOY.md)](Docs/ALIPAY_DEPLOY.md) - 支付宝付费开通会员配置。
|
||||||
|
|
||||||
### 开发文档
|
### 开发文档
|
||||||
- [后端开发指南](Docs/BACKEND_README.md) - 接口规范与开发流程。
|
- [后端开发指南](Docs/BACKEND_README.md) - 接口规范与开发流程。
|
||||||
|
|||||||
@@ -71,5 +71,10 @@ GLM_MODEL=glm-4.7-flash
|
|||||||
SUPABASE_STORAGE_LOCAL_PATH=/home/rongye/ProgramFiles/Supabase/volumes/storage/stub/stub
|
SUPABASE_STORAGE_LOCAL_PATH=/home/rongye/ProgramFiles/Supabase/volumes/storage/stub/stub
|
||||||
|
|
||||||
# =============== 抖音视频下载 Cookie ===============
|
# =============== 抖音视频下载 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://vigent.hbyrkj.top/api/payment/notify
|
||||||
|
ALIPAY_RETURN_URL=https://vigent.hbyrkj.top/pay
|
||||||
|
|||||||
@@ -76,12 +76,18 @@ class Settings(BaseSettings):
|
|||||||
GLM_API_KEY: str = ""
|
GLM_API_KEY: str = ""
|
||||||
GLM_MODEL: str = "glm-4.7-flash"
|
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 配置 (逗号分隔的域名列表,* 表示允许所有)
|
||||||
CORS_ORIGINS: str = "*"
|
CORS_ORIGINS: str = "*"
|
||||||
|
|
||||||
# 抖音 Cookie (用于视频下载功能,会过期需要定期更新)
|
|
||||||
DOUYIN_COOKIE: str = ""
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def LATENTSYNC_DIR(self) -> Path:
|
def LATENTSYNC_DIR(self) -> Path:
|
||||||
"""LatentSync 目录路径 (动态计算)"""
|
"""LatentSync 目录路径 (动态计算)"""
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
"""
|
"""
|
||||||
依赖注入模块:认证和用户获取
|
依赖注入模块:认证和用户获取
|
||||||
"""
|
"""
|
||||||
from typing import Optional, Any, Dict, cast
|
from typing import Optional, Any, Dict, cast
|
||||||
from fastapi import Request, HTTPException, Depends, status
|
from fastapi import Request, HTTPException, Depends, status
|
||||||
from app.core.security import decode_access_token
|
from app.core.security import decode_access_token
|
||||||
from app.repositories.sessions import get_session, delete_sessions
|
from app.repositories.sessions import get_session, delete_sessions
|
||||||
from app.repositories.users import get_user_by_id, deactivate_user_if_expired
|
from app.repositories.users import get_user_by_id, deactivate_user_if_expired
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
|
|
||||||
async def get_token_from_cookie(request: Request) -> Optional[str]:
|
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")
|
return request.cookies.get("access_token")
|
||||||
|
|
||||||
|
|
||||||
async def get_current_user_optional(
|
async def get_current_user_optional(
|
||||||
request: Request
|
request: Request
|
||||||
) -> Optional[Dict[str, Any]]:
|
) -> Optional[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
获取当前用户 (可选,未登录返回 None)
|
获取当前用户 (可选,未登录返回 None)
|
||||||
"""
|
"""
|
||||||
@@ -29,26 +29,30 @@ async def get_current_user_optional(
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
# 验证 session_token 是否有效 (单设备登录检查)
|
# 验证 session_token 是否有效 (单设备登录检查)
|
||||||
try:
|
try:
|
||||||
session = get_session(token_data.user_id, token_data.session_token)
|
session = get_session(token_data.user_id, token_data.session_token)
|
||||||
if not session:
|
if not session:
|
||||||
logger.warning(f"Session token 无效: user_id={token_data.user_id}")
|
logger.warning(f"Session token 无效: user_id={token_data.user_id}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
user = cast(Optional[Dict[str, Any]], get_user_by_id(token_data.user_id))
|
user = cast(Optional[Dict[str, Any]], get_user_by_id(token_data.user_id))
|
||||||
if user and deactivate_user_if_expired(user):
|
if user and deactivate_user_if_expired(user):
|
||||||
delete_sessions(token_data.user_id)
|
delete_sessions(token_data.user_id)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
return user
|
if user and not user.get("is_active"):
|
||||||
except Exception as e:
|
delete_sessions(token_data.user_id)
|
||||||
logger.error(f"获取用户信息失败: {e}")
|
return None
|
||||||
return None
|
|
||||||
|
return user
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"获取用户信息失败: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
async def get_current_user(
|
async def get_current_user(
|
||||||
request: Request
|
request: Request
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
获取当前用户 (必须登录)
|
获取当前用户 (必须登录)
|
||||||
|
|
||||||
@@ -70,38 +74,45 @@ async def get_current_user(
|
|||||||
detail="Token 无效或已过期"
|
detail="Token 无效或已过期"
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
session = get_session(token_data.user_id, token_data.session_token)
|
session = get_session(token_data.user_id, token_data.session_token)
|
||||||
if not session:
|
if not session:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail="会话已失效,请重新登录(可能已在其他设备登录)"
|
detail="会话已失效,请重新登录(可能已在其他设备登录)"
|
||||||
)
|
)
|
||||||
|
|
||||||
user = get_user_by_id(token_data.user_id)
|
user = get_user_by_id(token_data.user_id)
|
||||||
if not user:
|
if not user:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
detail="用户不存在"
|
detail="用户不存在"
|
||||||
)
|
)
|
||||||
user = cast(Dict[str, Any], user)
|
user = cast(Dict[str, Any], user)
|
||||||
|
|
||||||
if deactivate_user_if_expired(user):
|
if deactivate_user_if_expired(user):
|
||||||
delete_sessions(token_data.user_id)
|
delete_sessions(token_data.user_id)
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail="会员已到期,请续费"
|
detail="会员已到期,请续费"
|
||||||
)
|
)
|
||||||
|
|
||||||
return user
|
if not user.get("is_active"):
|
||||||
except HTTPException:
|
delete_sessions(token_data.user_id)
|
||||||
raise
|
raise HTTPException(
|
||||||
except Exception as e:
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
logger.error(f"获取用户信息失败: {e}")
|
detail="账号已停用"
|
||||||
raise HTTPException(
|
)
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
||||||
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(
|
async def get_current_admin(
|
||||||
|
|||||||
@@ -110,3 +110,28 @@ def set_auth_cookie(response: Response, token: str) -> None:
|
|||||||
def clear_auth_cookie(response: Response) -> None:
|
def clear_auth_cookie(response: Response) -> None:
|
||||||
"""清除认证 Cookie"""
|
"""清除认证 Cookie"""
|
||||||
response.delete_cookie(key="access_token")
|
response.delete_cookie(key="access_token")
|
||||||
|
|
||||||
|
|
||||||
|
def create_payment_token(user_id: str) -> str:
|
||||||
|
"""生成付费专用短期 JWT token(30 分钟有效)"""
|
||||||
|
payload = {
|
||||||
|
"sub": user_id,
|
||||||
|
"purpose": "payment",
|
||||||
|
"exp": datetime.now(timezone.utc) + timedelta(minutes=30),
|
||||||
|
}
|
||||||
|
return jwt.encode(payload, settings.JWT_SECRET_KEY, algorithm=settings.JWT_ALGORITHM)
|
||||||
|
|
||||||
|
|
||||||
|
def decode_payment_token(token: str) -> str | None:
|
||||||
|
"""解析 payment_token,返回 user_id(仅 purpose=payment 有效)"""
|
||||||
|
try:
|
||||||
|
data = jwt.decode(
|
||||||
|
token,
|
||||||
|
settings.JWT_SECRET_KEY,
|
||||||
|
algorithms=[settings.JWT_ALGORITHM],
|
||||||
|
)
|
||||||
|
if data.get("purpose") != "payment":
|
||||||
|
return None
|
||||||
|
return data.get("sub")
|
||||||
|
except JWTError:
|
||||||
|
return None
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ from app.modules.ai.router import router as ai_router
|
|||||||
from app.modules.tools.router import router as tools_router
|
from app.modules.tools.router import router as tools_router
|
||||||
from app.modules.assets.router import router as assets_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.generated_audios.router import router as generated_audios_router
|
||||||
|
from app.modules.payment.router import router as payment_router
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
import os
|
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(tools_router, prefix="/api/tools", tags=["Tools"])
|
||||||
app.include_router(assets_router, prefix="/api/assets", tags=["Assets"])
|
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(generated_audios_router, prefix="/api/generated-audios", tags=["GeneratedAudios"])
|
||||||
|
app.include_router(payment_router) # /api/payment
|
||||||
|
|
||||||
|
|
||||||
@app.on_event("startup")
|
@app.on_event("startup")
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ class GenerateMetaRequest(BaseModel):
|
|||||||
class GenerateMetaResponse(BaseModel):
|
class GenerateMetaResponse(BaseModel):
|
||||||
"""生成标题标签响应"""
|
"""生成标题标签响应"""
|
||||||
title: str
|
title: str
|
||||||
|
secondary_title: str = ""
|
||||||
tags: list[str]
|
tags: list[str]
|
||||||
|
|
||||||
|
|
||||||
@@ -66,6 +67,7 @@ async def generate_meta(req: GenerateMetaRequest):
|
|||||||
result = await glm_service.generate_title_tags(req.text)
|
result = await glm_service.generate_title_tags(req.text)
|
||||||
return success_response(GenerateMetaResponse(
|
return success_response(GenerateMetaResponse(
|
||||||
title=result.get("title", ""),
|
title=result.get("title", ""),
|
||||||
|
secondary_title=result.get("secondary_title", ""),
|
||||||
tags=result.get("tags", [])
|
tags=result.get("tags", [])
|
||||||
).model_dump())
|
).model_dump())
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -1,30 +1,32 @@
|
|||||||
"""
|
"""
|
||||||
认证 API:注册、登录、登出、修改密码
|
认证 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 pydantic import BaseModel, field_validator
|
||||||
from app.core.security import (
|
from app.core.security import (
|
||||||
get_password_hash,
|
get_password_hash,
|
||||||
verify_password,
|
verify_password,
|
||||||
create_access_token,
|
create_access_token,
|
||||||
generate_session_token,
|
generate_session_token,
|
||||||
set_auth_cookie,
|
set_auth_cookie,
|
||||||
clear_auth_cookie,
|
clear_auth_cookie,
|
||||||
decode_access_token
|
decode_access_token,
|
||||||
)
|
create_payment_token,
|
||||||
from app.repositories.sessions import create_session, delete_sessions
|
)
|
||||||
from app.repositories.users import (
|
from app.repositories.sessions import create_session, delete_sessions
|
||||||
create_user,
|
from app.repositories.users import (
|
||||||
get_user_by_id,
|
create_user,
|
||||||
get_user_by_phone,
|
get_user_by_id,
|
||||||
user_exists_by_phone,
|
get_user_by_phone,
|
||||||
update_user,
|
user_exists_by_phone,
|
||||||
deactivate_user_if_expired,
|
update_user,
|
||||||
)
|
deactivate_user_if_expired,
|
||||||
from app.core.deps import get_current_user
|
)
|
||||||
from app.core.response import success_response
|
from app.core.deps import get_current_user
|
||||||
|
from app.core.response import success_response
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
from typing import Optional, Any, cast
|
from typing import Optional, Any, cast
|
||||||
import re
|
import re
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/auth", tags=["认证"])
|
router = APIRouter(prefix="/api/auth", tags=["认证"])
|
||||||
@@ -84,26 +86,26 @@ async def register(request: RegisterRequest):
|
|||||||
注册后状态为 pending,需要管理员激活
|
注册后状态为 pending,需要管理员激活
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
if user_exists_by_phone(request.phone):
|
if user_exists_by_phone(request.phone):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
detail="该手机号已注册"
|
detail="该手机号已注册"
|
||||||
)
|
)
|
||||||
|
|
||||||
# 创建用户
|
# 创建用户
|
||||||
password_hash = get_password_hash(request.password)
|
password_hash = get_password_hash(request.password)
|
||||||
|
|
||||||
create_user({
|
create_user({
|
||||||
"phone": request.phone,
|
"phone": request.phone,
|
||||||
"password_hash": password_hash,
|
"password_hash": password_hash,
|
||||||
"username": request.username or f"用户{request.phone[-4:]}",
|
"username": request.username or f"用户{request.phone[-4:]}",
|
||||||
"role": "pending",
|
"role": "pending",
|
||||||
"is_active": False
|
"is_active": False
|
||||||
})
|
})
|
||||||
|
|
||||||
logger.info(f"新用户注册: {request.phone}")
|
logger.info(f"新用户注册: {request.phone}")
|
||||||
|
|
||||||
return success_response(message="注册成功,请等待管理员审核激活")
|
return success_response(message="注册成功,请等待管理员审核激活")
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -124,12 +126,12 @@ async def login(request: LoginRequest, response: Response):
|
|||||||
- 实现"后踢前"单设备登录
|
- 实现"后踢前"单设备登录
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
user = cast(dict[str, Any], get_user_by_phone(request.phone) or {})
|
user = cast(dict[str, Any], get_user_by_phone(request.phone) or {})
|
||||||
if not user:
|
if not user:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
detail="手机号或密码错误"
|
detail="手机号或密码错误"
|
||||||
)
|
)
|
||||||
|
|
||||||
# 验证密码
|
# 验证密码
|
||||||
if not verify_password(request.password, user["password_hash"]):
|
if not verify_password(request.password, user["password_hash"]):
|
||||||
@@ -138,27 +140,33 @@ async def login(request: LoginRequest, response: Response):
|
|||||||
detail="手机号或密码错误"
|
detail="手机号或密码错误"
|
||||||
)
|
)
|
||||||
|
|
||||||
# 授权过期时自动停用账号
|
# 过期自动停用(注意:只更新 DB,不修改内存中的 user 字典)
|
||||||
if deactivate_user_if_expired(user):
|
expired = deactivate_user_if_expired(user)
|
||||||
delete_sessions(user["id"])
|
if expired:
|
||||||
raise HTTPException(
|
delete_sessions(user["id"])
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
|
||||||
detail="会员已到期,请续费"
|
# 过期 或 未激活(新注册)→ 返回付费指引
|
||||||
)
|
if expired or not user["is_active"]:
|
||||||
|
payment_token = create_payment_token(user["id"])
|
||||||
# 检查是否激活
|
return JSONResponse(
|
||||||
if not user["is_active"]:
|
status_code=403,
|
||||||
raise HTTPException(
|
content={
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
"success": False,
|
||||||
detail="账号未激活,请等待管理员审核"
|
"message": "请付费开通会员",
|
||||||
)
|
"code": 403,
|
||||||
|
"data": {
|
||||||
|
"reason": "PAYMENT_REQUIRED",
|
||||||
|
"payment_token": payment_token,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
# 生成新的 session_token (后踢前)
|
# 生成新的 session_token (后踢前)
|
||||||
session_token = generate_session_token()
|
session_token = generate_session_token()
|
||||||
|
|
||||||
# 删除旧 session,插入新 session
|
# 删除旧 session,插入新 session
|
||||||
delete_sessions(user["id"])
|
delete_sessions(user["id"])
|
||||||
create_session(user["id"], session_token, None)
|
create_session(user["id"], session_token, None)
|
||||||
|
|
||||||
# 生成 JWT Token
|
# 生成 JWT Token
|
||||||
token = create_access_token(user["id"], session_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}")
|
logger.info(f"用户登录: {request.phone}")
|
||||||
|
|
||||||
return success_response(
|
return success_response(
|
||||||
data={
|
data={
|
||||||
"user": UserResponse(
|
"user": UserResponse(
|
||||||
id=user["id"],
|
id=user["id"],
|
||||||
phone=user["phone"],
|
phone=user["phone"],
|
||||||
username=user.get("username"),
|
username=user.get("username"),
|
||||||
role=user["role"],
|
role=user["role"],
|
||||||
is_active=user["is_active"],
|
is_active=user["is_active"],
|
||||||
expires_at=user.get("expires_at")
|
expires_at=user.get("expires_at")
|
||||||
).model_dump()
|
).model_dump()
|
||||||
},
|
},
|
||||||
message="登录成功",
|
message="登录成功",
|
||||||
)
|
)
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -192,10 +200,10 @@ async def login(request: LoginRequest, response: Response):
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/logout")
|
@router.post("/logout")
|
||||||
async def logout(response: Response):
|
async def logout(response: Response):
|
||||||
"""用户登出"""
|
"""用户登出"""
|
||||||
clear_auth_cookie(response)
|
clear_auth_cookie(response)
|
||||||
return success_response(message="已登出")
|
return success_response(message="已登出")
|
||||||
|
|
||||||
|
|
||||||
@router.post("/change-password")
|
@router.post("/change-password")
|
||||||
@@ -223,12 +231,12 @@ async def change_password(request: ChangePasswordRequest, req: Request, response
|
|||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
user = cast(dict[str, Any], get_user_by_id(token_data.user_id) or {})
|
user = cast(dict[str, Any], get_user_by_id(token_data.user_id) or {})
|
||||||
if not user:
|
if not user:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
detail="用户不存在"
|
detail="用户不存在"
|
||||||
)
|
)
|
||||||
|
|
||||||
# 验证当前密码
|
# 验证当前密码
|
||||||
if not verify_password(request.old_password, user["password_hash"]):
|
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)
|
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 失效
|
# 生成新的 session token,使旧 token 失效
|
||||||
new_session_token = generate_session_token()
|
new_session_token = generate_session_token()
|
||||||
|
|
||||||
delete_sessions(user["id"])
|
delete_sessions(user["id"])
|
||||||
create_session(user["id"], new_session_token, None)
|
create_session(user["id"], new_session_token, None)
|
||||||
|
|
||||||
# 生成新的 JWT Token
|
# 生成新的 JWT Token
|
||||||
new_token = create_access_token(user["id"], new_session_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']}")
|
logger.info(f"用户修改密码: {user['phone']}")
|
||||||
|
|
||||||
return success_response(message="密码修改成功")
|
return success_response(message="密码修改成功")
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -264,14 +272,14 @@ async def change_password(request: ChangePasswordRequest, req: Request, response
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/me")
|
@router.get("/me")
|
||||||
async def get_me(user: dict = Depends(get_current_user)):
|
async def get_me(user: dict = Depends(get_current_user)):
|
||||||
"""获取当前用户信息"""
|
"""获取当前用户信息"""
|
||||||
return success_response(UserResponse(
|
return success_response(UserResponse(
|
||||||
id=user["id"],
|
id=user["id"],
|
||||||
phone=user["phone"],
|
phone=user["phone"],
|
||||||
username=user.get("username"),
|
username=user.get("username"),
|
||||||
role=user["role"],
|
role=user["role"],
|
||||||
is_active=user["is_active"],
|
is_active=user["is_active"],
|
||||||
expires_at=user.get("expires_at")
|
expires_at=user.get("expires_at")
|
||||||
).model_dump())
|
).model_dump())
|
||||||
|
|||||||
0
backend/app/modules/payment/__init__.py
Normal file
0
backend/app/modules/payment/__init__.py
Normal file
52
backend/app/modules/payment/router.py
Normal file
52
backend/app/modules/payment/router.py
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
"""
|
||||||
|
支付 API:创建订单、异步通知、状态查询
|
||||||
|
|
||||||
|
遵循 BACKEND_DEV.md 规范:router 只做参数校验、调用 service、返回统一响应
|
||||||
|
"""
|
||||||
|
from fastapi import APIRouter, HTTPException, Request, status
|
||||||
|
from fastapi.responses import PlainTextResponse
|
||||||
|
|
||||||
|
from app.core.response import success_response
|
||||||
|
from .schemas import CreateOrderRequest, CreateOrderResponse, OrderStatusResponse
|
||||||
|
from . import service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/payment", tags=["支付"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/create-order")
|
||||||
|
async def create_payment_order(request: CreateOrderRequest):
|
||||||
|
"""创建支付宝电脑网站支付订单,返回收银台 URL"""
|
||||||
|
try:
|
||||||
|
result = service.create_payment_order(request.payment_token)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=str(e))
|
||||||
|
except RuntimeError as e:
|
||||||
|
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e))
|
||||||
|
|
||||||
|
return success_response(
|
||||||
|
CreateOrderResponse(**result).model_dump()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/notify")
|
||||||
|
async def payment_notify(request: Request):
|
||||||
|
"""
|
||||||
|
支付宝异步通知回调
|
||||||
|
|
||||||
|
必须返回纯文本 "success"(不是 JSON),否则支付宝会重复推送。
|
||||||
|
"""
|
||||||
|
form_data = await request.form()
|
||||||
|
verified = service.handle_payment_notify(dict(form_data))
|
||||||
|
return PlainTextResponse("success" if verified else "fail")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/status/{out_trade_no}")
|
||||||
|
async def check_payment_status(out_trade_no: str):
|
||||||
|
"""查询订单支付状态(前端轮询)"""
|
||||||
|
order_status = service.get_order_status(out_trade_no)
|
||||||
|
if order_status is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="订单不存在")
|
||||||
|
|
||||||
|
return success_response(
|
||||||
|
OrderStatusResponse(status=order_status).model_dump()
|
||||||
|
)
|
||||||
15
backend/app/modules/payment/schemas.py
Normal file
15
backend/app/modules/payment/schemas.py
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class CreateOrderRequest(BaseModel):
|
||||||
|
payment_token: str
|
||||||
|
|
||||||
|
|
||||||
|
class CreateOrderResponse(BaseModel):
|
||||||
|
pay_url: str
|
||||||
|
out_trade_no: str
|
||||||
|
amount: float
|
||||||
|
|
||||||
|
|
||||||
|
class OrderStatusResponse(BaseModel):
|
||||||
|
status: str
|
||||||
137
backend/app/modules/payment/service.py
Normal file
137
backend/app/modules/payment/service.py
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
"""
|
||||||
|
支付业务服务
|
||||||
|
|
||||||
|
职责:Alipay SDK 封装、创建订单、处理支付通知、查询状态
|
||||||
|
遵循 BACKEND_DEV.md "薄路由 + 厚服务" 原则
|
||||||
|
"""
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from alipay import AliPay
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.security import decode_payment_token
|
||||||
|
from app.repositories.orders import create_order, get_order_by_trade_no, update_order_status
|
||||||
|
from app.repositories.users import update_user
|
||||||
|
|
||||||
|
# 支付宝网关地址
|
||||||
|
ALIPAY_GATEWAY = "https://openapi.alipay.com/gateway.do"
|
||||||
|
ALIPAY_GATEWAY_SANDBOX = "https://openapi-sandbox.dl.alipaydev.com/gateway.do"
|
||||||
|
|
||||||
|
|
||||||
|
def _get_alipay_client() -> AliPay:
|
||||||
|
"""延迟初始化 Alipay 客户端"""
|
||||||
|
return AliPay(
|
||||||
|
appid=settings.ALIPAY_APP_ID,
|
||||||
|
app_notify_url=settings.ALIPAY_NOTIFY_URL,
|
||||||
|
app_private_key_string=open(settings.ALIPAY_PRIVATE_KEY_PATH).read(),
|
||||||
|
alipay_public_key_string=open(settings.ALIPAY_PUBLIC_KEY_PATH).read(),
|
||||||
|
sign_type="RSA2",
|
||||||
|
debug=settings.ALIPAY_SANDBOX,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _create_page_pay_url(out_trade_no: str, amount: float, subject: str) -> str | None:
|
||||||
|
"""调用 alipay.trade.page.pay,返回支付宝收银台 URL"""
|
||||||
|
client = _get_alipay_client()
|
||||||
|
order_string = client.api_alipay_trade_page_pay(
|
||||||
|
subject=subject,
|
||||||
|
out_trade_no=out_trade_no,
|
||||||
|
total_amount=amount,
|
||||||
|
return_url=settings.ALIPAY_RETURN_URL,
|
||||||
|
)
|
||||||
|
if not order_string:
|
||||||
|
logger.error(f"电脑网站支付下单失败: {out_trade_no}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
gateway = ALIPAY_GATEWAY_SANDBOX if settings.ALIPAY_SANDBOX else ALIPAY_GATEWAY
|
||||||
|
pay_url = f"{gateway}?{order_string}"
|
||||||
|
logger.info(f"电脑网站支付下单成功: {out_trade_no}")
|
||||||
|
return pay_url
|
||||||
|
|
||||||
|
|
||||||
|
def _verify_signature(data: dict, signature: str) -> bool:
|
||||||
|
"""验证支付宝异步通知签名"""
|
||||||
|
client = _get_alipay_client()
|
||||||
|
return client.verify(data, signature)
|
||||||
|
|
||||||
|
|
||||||
|
def create_payment_order(payment_token: str) -> dict:
|
||||||
|
"""
|
||||||
|
创建支付订单完整流程
|
||||||
|
|
||||||
|
Returns: {"pay_url": str, "out_trade_no": str, "amount": float}
|
||||||
|
Raises: ValueError (token 无效), RuntimeError (API 失败)
|
||||||
|
"""
|
||||||
|
user_id = decode_payment_token(payment_token)
|
||||||
|
if not user_id:
|
||||||
|
raise ValueError("付费凭证无效或已过期,请重新登录")
|
||||||
|
|
||||||
|
out_trade_no = f"VG_{int(datetime.now().timestamp())}_{uuid.uuid4().hex[:8]}"
|
||||||
|
amount = settings.PAYMENT_AMOUNT
|
||||||
|
|
||||||
|
create_order(user_id, out_trade_no, amount)
|
||||||
|
|
||||||
|
pay_url = _create_page_pay_url(out_trade_no, amount, "IPAgent 会员开通")
|
||||||
|
if not pay_url:
|
||||||
|
raise RuntimeError("创建支付订单失败,请稍后重试")
|
||||||
|
|
||||||
|
logger.info(f"用户 {user_id} 创建支付订单: {out_trade_no}")
|
||||||
|
|
||||||
|
return {"pay_url": pay_url, "out_trade_no": out_trade_no, "amount": amount}
|
||||||
|
|
||||||
|
|
||||||
|
def handle_payment_notify(form_data: dict) -> bool:
|
||||||
|
"""
|
||||||
|
处理支付宝异步通知完整流程
|
||||||
|
|
||||||
|
Returns: True=验签通过, False=验签失败
|
||||||
|
"""
|
||||||
|
data = dict(form_data)
|
||||||
|
|
||||||
|
signature = data.pop("sign", "")
|
||||||
|
data.pop("sign_type", None)
|
||||||
|
|
||||||
|
if not _verify_signature(data, signature):
|
||||||
|
logger.warning(f"支付宝通知验签失败: {data.get('out_trade_no')}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
out_trade_no = data.get("out_trade_no", "")
|
||||||
|
trade_status = data.get("trade_status", "")
|
||||||
|
trade_no = data.get("trade_no", "")
|
||||||
|
|
||||||
|
logger.info(f"收到支付宝通知: {out_trade_no}, status={trade_status}, trade_no={trade_no}")
|
||||||
|
|
||||||
|
if trade_status not in ("TRADE_SUCCESS", "TRADE_FINISHED"):
|
||||||
|
return True
|
||||||
|
|
||||||
|
order = get_order_by_trade_no(out_trade_no)
|
||||||
|
if not order:
|
||||||
|
logger.warning(f"订单不存在: {out_trade_no}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
if order["status"] == "paid":
|
||||||
|
logger.info(f"订单已处理过: {out_trade_no}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
update_order_status(out_trade_no, "paid", trade_no)
|
||||||
|
|
||||||
|
user_id = order["user_id"]
|
||||||
|
expires_at = (datetime.now(timezone.utc) + timedelta(days=settings.PAYMENT_EXPIRE_DAYS)).isoformat()
|
||||||
|
update_user(user_id, {
|
||||||
|
"is_active": True,
|
||||||
|
"role": "user",
|
||||||
|
"expires_at": expires_at,
|
||||||
|
})
|
||||||
|
|
||||||
|
logger.success(f"用户 {user_id} 支付成功,已激活,有效期至 {expires_at}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def get_order_status(out_trade_no: str) -> str | None:
|
||||||
|
"""查询订单支付状态"""
|
||||||
|
order = get_order_by_trade_no(out_trade_no)
|
||||||
|
if not order:
|
||||||
|
return None
|
||||||
|
return order["status"]
|
||||||
@@ -2,9 +2,11 @@ import re
|
|||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
import json
|
import json
|
||||||
|
import hashlib
|
||||||
import asyncio
|
import asyncio
|
||||||
import subprocess
|
import subprocess
|
||||||
import tempfile
|
import tempfile
|
||||||
|
import unicodedata
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
@@ -19,8 +21,16 @@ BUCKET_REF_AUDIOS = "ref-audios"
|
|||||||
|
|
||||||
|
|
||||||
def sanitize_filename(filename: str) -> str:
|
def sanitize_filename(filename: str) -> str:
|
||||||
"""清理文件名,移除特殊字符"""
|
"""清理文件名用于 Storage key(仅保留 ASCII 安全字符)。"""
|
||||||
safe_name = re.sub(r'[<>:"/\\|?*\s]', '_', filename)
|
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:
|
if len(safe_name) > 50:
|
||||||
ext = Path(safe_name).suffix
|
ext = Path(safe_name).suffix
|
||||||
safe_name = safe_name[:50 - len(ext)] + ext
|
safe_name = safe_name[:50 - len(ext)] + ext
|
||||||
|
|||||||
@@ -13,11 +13,12 @@ router = APIRouter()
|
|||||||
async def extract_script_tool(
|
async def extract_script_tool(
|
||||||
file: Optional[UploadFile] = File(None),
|
file: Optional[UploadFile] = File(None),
|
||||||
url: Optional[str] = Form(None),
|
url: Optional[str] = Form(None),
|
||||||
rewrite: bool = Form(True)
|
rewrite: bool = Form(True),
|
||||||
|
custom_prompt: Optional[str] = Form(None)
|
||||||
):
|
):
|
||||||
"""独立文案提取工具"""
|
"""独立文案提取工具"""
|
||||||
try:
|
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)
|
return success_response(result)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(400, str(e))
|
raise HTTPException(400, str(e))
|
||||||
|
|||||||
@@ -17,9 +17,9 @@ from app.services.whisper_service import whisper_service
|
|||||||
from app.services.glm_service import glm_service
|
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:
|
if not file and not url:
|
||||||
raise ValueError("必须提供文件或视频链接")
|
raise ValueError("必须提供文件或视频链接")
|
||||||
@@ -63,11 +63,11 @@ async def extract_script(file=None, url: Optional[str] = None, rewrite: bool = T
|
|||||||
# 2. 提取文案 (Whisper)
|
# 2. 提取文案 (Whisper)
|
||||||
script = await whisper_service.transcribe(str(audio_path))
|
script = await whisper_service.transcribe(str(audio_path))
|
||||||
|
|
||||||
# 3. AI 洗稿 (GLM)
|
# 3. AI 改写 (GLM)
|
||||||
rewritten = None
|
rewritten = None
|
||||||
if rewrite and script and len(script.strip()) > 0:
|
if rewrite and script and len(script.strip()) > 0:
|
||||||
logger.info("Rewriting script...")
|
logger.info("Rewriting script...")
|
||||||
rewritten = await glm_service.rewrite_script(script)
|
rewritten = await glm_service.rewrite_script(script, custom_prompt)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"original_script": script,
|
"original_script": script,
|
||||||
@@ -156,125 +156,120 @@ def _download_yt_dlp(url_value: str, temp_dir: Path, timestamp: int) -> Path:
|
|||||||
'quiet': True,
|
'quiet': True,
|
||||||
'no_warnings': True,
|
'no_warnings': True,
|
||||||
'http_headers': {
|
'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/',
|
'Referer': 'https://www.douyin.com/',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
with yt_dlp.YoutubeDL() as ydl_raw:
|
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||||||
ydl: Any = ydl_raw
|
|
||||||
ydl.params.update(ydl_opts)
|
|
||||||
info = ydl.extract_info(url_value, download=True)
|
info = ydl.extract_info(url_value, download=True)
|
||||||
if 'requested_downloads' in info:
|
if 'requested_downloads' in info:
|
||||||
downloaded_file = info['requested_downloads'][0]['filepath']
|
downloaded_file = info['requested_downloads'][0]['filepath']
|
||||||
else:
|
else:
|
||||||
ext = info.get('ext', 'mp4')
|
ext = info.get('ext', 'mp4')
|
||||||
id = info.get('id')
|
vid_id = info.get('id')
|
||||||
downloaded_file = str(temp_dir / f"tool_download_{timestamp}_{id}.{ext}")
|
downloaded_file = str(temp_dir / f"tool_download_{timestamp}_{vid_id}.{ext}")
|
||||||
|
|
||||||
return Path(downloaded_file)
|
return Path(downloaded_file)
|
||||||
|
|
||||||
|
|
||||||
async def _download_douyin_manual(url: str, temp_dir: Path, timestamp: int) -> Optional[Path]:
|
async def _download_douyin_manual(url: str, temp_dir: Path, timestamp: int) -> Optional[Path]:
|
||||||
"""手动下载抖音视频 (Fallback)"""
|
"""手动下载抖音视频 (Fallback) — 通过移动端分享页获取播放地址"""
|
||||||
logger.info(f"[SuperIPAgent] Starting download for: {url}")
|
logger.info(f"[douyin-fallback] Starting download for: {url}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
# 1. 解析短链接,提取视频 ID
|
||||||
headers = {
|
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:
|
async with httpx.AsyncClient(follow_redirects=True, timeout=10.0) as client:
|
||||||
resp = await client.get(url, headers=headers)
|
resp = await client.get(url, headers=headers)
|
||||||
final_url = str(resp.url)
|
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)
|
match = re.search(r'/video/(\d+)', final_url)
|
||||||
if match:
|
if match:
|
||||||
modal_id = match.group(1)
|
video_id = match.group(1)
|
||||||
|
|
||||||
if not modal_id:
|
if not video_id:
|
||||||
logger.error("[SuperIPAgent] Could not extract modal_id")
|
logger.error("[douyin-fallback] Could not extract video_id")
|
||||||
return None
|
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
|
# 3. 访问移动端分享页提取播放地址
|
||||||
if not settings.DOUYIN_COOKIE:
|
page_headers = {
|
||||||
logger.warning("[SuperIPAgent] DOUYIN_COOKIE 未配置,视频下载可能失败")
|
"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 "",
|
||||||
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",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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:
|
page_text = page_resp.text
|
||||||
response = await client.get(target_url, headers=headers_with_cookie)
|
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)
|
# 4. 提取 play_addr
|
||||||
if not content_match:
|
addr_match = re.search(
|
||||||
if "SSR_HYDRATED_DATA" in response.text:
|
r'"play_addr":\{"uri":"([^"]+)","url_list":\["([^"]+)"',
|
||||||
content_match = re.findall(r'<script id="SSR_HYDRATED_DATA" type="application/json">(.*?)</script>', response.text)
|
page_text,
|
||||||
|
)
|
||||||
if not content_match:
|
if not addr_match:
|
||||||
logger.error(f"[SuperIPAgent] Could not find RENDER_DATA in page (len={len(response.text)})")
|
logger.error("[douyin-fallback] Could not find play_addr in mobile page")
|
||||||
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")
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
video_url = addr_match.group(2).replace(r"\u002F", "/")
|
||||||
if video_url.startswith("//"):
|
if video_url.startswith("//"):
|
||||||
video_url = "https:" + video_url
|
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"
|
temp_path = temp_dir / f"douyin_manual_{timestamp}.mp4"
|
||||||
download_headers = {
|
download_headers = {
|
||||||
'Referer': 'https://www.douyin.com/',
|
"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',
|
"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:
|
async with client.stream("GET", video_url, headers=download_headers) as dl_resp:
|
||||||
if dl_resp.status_code == 200:
|
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):
|
async for chunk in dl_resp.aiter_bytes(chunk_size=8192):
|
||||||
f.write(chunk)
|
f.write(chunk)
|
||||||
|
|
||||||
logger.info(f"[SuperIPAgent] Downloaded successfully: {temp_path}")
|
logger.info(f"[douyin-fallback] Downloaded successfully: {temp_path}")
|
||||||
return temp_path
|
return temp_path
|
||||||
else:
|
else:
|
||||||
logger.error(f"[SuperIPAgent] Download failed: {dl_resp.status_code}")
|
logger.error(f"[douyin-fallback] Download failed: {dl_resp.status_code}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[SuperIPAgent] Logic failed: {e}")
|
logger.error(f"[douyin-fallback] Logic failed: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -21,9 +21,15 @@ class GenerateRequest(BaseModel):
|
|||||||
language: str = "zh-CN"
|
language: str = "zh-CN"
|
||||||
generated_audio_id: Optional[str] = None # 预生成配音 ID(存在时跳过内联 TTS)
|
generated_audio_id: Optional[str] = None # 预生成配音 ID(存在时跳过内联 TTS)
|
||||||
title: Optional[str] = None
|
title: Optional[str] = None
|
||||||
|
title_display_mode: Literal["short", "persistent"] = "short"
|
||||||
|
title_duration: float = 4.0
|
||||||
enable_subtitles: bool = True
|
enable_subtitles: bool = True
|
||||||
subtitle_style_id: Optional[str] = None
|
subtitle_style_id: Optional[str] = None
|
||||||
title_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
|
subtitle_font_size: Optional[int] = None
|
||||||
title_font_size: Optional[int] = None
|
title_font_size: Optional[int] = None
|
||||||
title_top_margin: Optional[int] = None
|
title_top_margin: Optional[int] = None
|
||||||
|
|||||||
@@ -598,14 +598,17 @@ async def process_video_generation(task_id: str, req: GenerateRequest, user_id:
|
|||||||
else:
|
else:
|
||||||
logger.warning(f"BGM not found: {req.bgm_id}")
|
logger.warning(f"BGM not found: {req.bgm_id}")
|
||||||
|
|
||||||
use_remotion = (captions_path and captions_path.exists()) or req.title
|
use_remotion = (captions_path and captions_path.exists()) or req.title or req.secondary_title
|
||||||
|
|
||||||
subtitle_style = None
|
subtitle_style = None
|
||||||
title_style = None
|
title_style = None
|
||||||
|
secondary_title_style = None
|
||||||
if req.enable_subtitles:
|
if req.enable_subtitles:
|
||||||
subtitle_style = get_style("subtitle", req.subtitle_style_id) or get_default_style("subtitle")
|
subtitle_style = get_style("subtitle", req.subtitle_style_id) or get_default_style("subtitle")
|
||||||
if req.title:
|
if req.title:
|
||||||
title_style = get_style("title", req.title_style_id) or get_default_style("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 req.subtitle_font_size and req.enable_subtitles:
|
||||||
if subtitle_style is None:
|
if subtitle_style is None:
|
||||||
@@ -627,6 +630,16 @@ async def process_video_generation(task_id: str, req: GenerateRequest, user_id:
|
|||||||
subtitle_style = {}
|
subtitle_style = {}
|
||||||
subtitle_style["bottom_margin"] = int(req.subtitle_bottom_margin)
|
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:
|
if use_remotion:
|
||||||
subtitle_style = prepare_style_for_remotion(
|
subtitle_style = prepare_style_for_remotion(
|
||||||
subtitle_style,
|
subtitle_style,
|
||||||
@@ -638,6 +651,11 @@ async def process_video_generation(task_id: str, req: GenerateRequest, user_id:
|
|||||||
temp_dir,
|
temp_dir,
|
||||||
f"{task_id}_title_font"
|
f"{task_id}_title_font"
|
||||||
)
|
)
|
||||||
|
secondary_title_style = prepare_style_for_remotion(
|
||||||
|
secondary_title_style,
|
||||||
|
temp_dir,
|
||||||
|
f"{task_id}_secondary_title_font"
|
||||||
|
)
|
||||||
|
|
||||||
final_output_local_path = temp_dir / f"{task_id}_output.mp4"
|
final_output_local_path = temp_dir / f"{task_id}_output.mp4"
|
||||||
temp_files.append(final_output_local_path)
|
temp_files.append(final_output_local_path)
|
||||||
@@ -657,16 +675,26 @@ async def process_video_generation(task_id: str, req: GenerateRequest, user_id:
|
|||||||
mapped = 87 + int(percent * 0.08)
|
mapped = 87 + int(percent * 0.08)
|
||||||
_update_task(task_id, progress=mapped)
|
_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(
|
await remotion_service.render(
|
||||||
video_path=str(composed_video_path),
|
video_path=str(composed_video_path),
|
||||||
output_path=str(final_output_local_path),
|
output_path=str(final_output_local_path),
|
||||||
captions_path=str(captions_path) if captions_path else None,
|
captions_path=str(captions_path) if captions_path else None,
|
||||||
title=req.title,
|
title=req.title,
|
||||||
title_duration=3.0,
|
title_duration=title_duration,
|
||||||
|
title_display_mode=title_display_mode,
|
||||||
fps=25,
|
fps=25,
|
||||||
enable_subtitles=req.enable_subtitles,
|
enable_subtitles=req.enable_subtitles,
|
||||||
subtitle_style=subtitle_style,
|
subtitle_style=subtitle_style,
|
||||||
title_style=title_style,
|
title_style=title_style,
|
||||||
|
secondary_title=req.secondary_title,
|
||||||
|
secondary_title_style=secondary_title_style,
|
||||||
on_progress=on_remotion_progress
|
on_progress=on_remotion_progress
|
||||||
)
|
)
|
||||||
print(f"[Pipeline] Remotion render completed")
|
print(f"[Pipeline] Remotion render completed")
|
||||||
|
|||||||
34
backend/app/repositories/orders.py
Normal file
34
backend/app/repositories/orders.py
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
"""
|
||||||
|
订单数据访问层
|
||||||
|
"""
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any, Dict, Optional, cast
|
||||||
|
|
||||||
|
from app.core.supabase import get_supabase
|
||||||
|
|
||||||
|
|
||||||
|
def create_order(user_id: str, out_trade_no: str, amount: float) -> Dict[str, Any]:
|
||||||
|
supabase = get_supabase()
|
||||||
|
result = supabase.table("orders").insert({
|
||||||
|
"user_id": user_id,
|
||||||
|
"out_trade_no": out_trade_no,
|
||||||
|
"amount": amount,
|
||||||
|
"status": "pending",
|
||||||
|
}).execute()
|
||||||
|
return cast(Dict[str, Any], (result.data or [{}])[0])
|
||||||
|
|
||||||
|
|
||||||
|
def get_order_by_trade_no(out_trade_no: str) -> Optional[Dict[str, Any]]:
|
||||||
|
supabase = get_supabase()
|
||||||
|
result = supabase.table("orders").select("*").eq("out_trade_no", out_trade_no).single().execute()
|
||||||
|
return cast(Optional[Dict[str, Any]], result.data or None)
|
||||||
|
|
||||||
|
|
||||||
|
def update_order_status(out_trade_no: str, status: str, trade_no: str | None = None) -> None:
|
||||||
|
supabase = get_supabase()
|
||||||
|
payload: Dict[str, Any] = {"status": status}
|
||||||
|
if trade_no:
|
||||||
|
payload["trade_no"] = trade_no
|
||||||
|
if status == "paid":
|
||||||
|
payload["paid_at"] = datetime.now(timezone.utc).isoformat()
|
||||||
|
supabase.table("orders").update(payload).eq("out_trade_no", out_trade_no).execute()
|
||||||
@@ -35,18 +35,19 @@ class GLMService:
|
|||||||
Returns:
|
Returns:
|
||||||
{"title": "标题", "tags": ["标签1", "标签2", ...]}
|
{"title": "标题", "tags": ["标签1", "标签2", ...]}
|
||||||
"""
|
"""
|
||||||
prompt = f"""根据以下口播文案,生成一个吸引人的短视频标题和3个相关标签。
|
prompt = f"""根据以下口播文案,生成一个吸引人的短视频标题、副标题和3个相关标签。
|
||||||
|
|
||||||
口播文案:
|
口播文案:
|
||||||
{text}
|
{text}
|
||||||
|
|
||||||
要求:
|
要求:
|
||||||
1. 标题要简洁有力,能吸引观众点击,不超过10个字
|
1. 标题要简洁有力,能吸引观众点击,不超过10个字
|
||||||
2. 标签要与内容相关,便于搜索和推荐,只要3个
|
2. 副标题是对标题的补充说明或描述性文字,不超过20个字
|
||||||
3. 标题和标签必须使用与口播文案相同的语言(如文案是英文就用英文,日文就用日文)
|
3. 标签要与内容相关,便于搜索和推荐,只要3个
|
||||||
|
4. 标题、副标题和标签必须使用与口播文案相同的语言(如文案是英文就用英文,日文就用日文)
|
||||||
|
|
||||||
请严格按以下JSON格式返回(不要包含其他内容):
|
请严格按以下JSON格式返回(不要包含其他内容):
|
||||||
{{"title": "标题", "tags": ["标签1", "标签2", "标签3"]}}"""
|
{{"title": "标题", "secondary_title": "副标题", "tags": ["标签1", "标签2", "标签3"]}}"""
|
||||||
|
|
||||||
try:
|
try:
|
||||||
client = self._get_client()
|
client = self._get_client()
|
||||||
@@ -75,17 +76,24 @@ class GLMService:
|
|||||||
logger.error(f"GLM service error: {e}")
|
logger.error(f"GLM service error: {e}")
|
||||||
raise Exception(f"AI 生成失败: {str(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:
|
Args:
|
||||||
text: 原始文案
|
text: 原始文案
|
||||||
|
custom_prompt: 自定义提示词,为空则使用默认提示词
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
改写后的文案
|
改写后的文案
|
||||||
"""
|
"""
|
||||||
prompt = f"""请将以下视频文案进行改写。
|
if custom_prompt and custom_prompt.strip():
|
||||||
|
prompt = f"""{custom_prompt.strip()}
|
||||||
|
|
||||||
|
原始文案:
|
||||||
|
{text}"""
|
||||||
|
else:
|
||||||
|
prompt = f"""请将以下视频文案进行改写。
|
||||||
|
|
||||||
原始文案:
|
原始文案:
|
||||||
{text}
|
{text}
|
||||||
@@ -174,6 +182,8 @@ class GLMService:
|
|||||||
|
|
||||||
# 尝试提取 JSON 块
|
# 尝试提取 JSON 块
|
||||||
json_match = re.search(r'\{[^{}]*"title"[^{}]*"tags"[^{}]*\}', content, re.DOTALL)
|
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:
|
if json_match:
|
||||||
try:
|
try:
|
||||||
return json.loads(json_match.group())
|
return json.loads(json_match.group())
|
||||||
|
|||||||
@@ -369,7 +369,7 @@ class LipSyncService:
|
|||||||
}
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=1200.0) as client:
|
async with httpx.AsyncClient(timeout=3600.0) as client:
|
||||||
# 先检查健康状态
|
# 先检查健康状态
|
||||||
try:
|
try:
|
||||||
resp = await client.get(f"{server_url}/health", timeout=5.0)
|
resp = await client.get(f"{server_url}/health", timeout=5.0)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import asyncio
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
|
from collections.abc import Callable
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
@@ -29,12 +30,15 @@ class RemotionService:
|
|||||||
output_path: str,
|
output_path: str,
|
||||||
captions_path: Optional[str] = None,
|
captions_path: Optional[str] = None,
|
||||||
title: 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,
|
fps: int = 25,
|
||||||
enable_subtitles: bool = True,
|
enable_subtitles: bool = True,
|
||||||
subtitle_style: Optional[dict] = None,
|
subtitle_style: Optional[dict] = None,
|
||||||
title_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:
|
) -> str:
|
||||||
"""
|
"""
|
||||||
使用 Remotion 渲染视频(添加字幕和标题)
|
使用 Remotion 渲染视频(添加字幕和标题)
|
||||||
@@ -45,6 +49,7 @@ class RemotionService:
|
|||||||
captions_path: 字幕 JSON 文件路径(Whisper 生成)
|
captions_path: 字幕 JSON 文件路径(Whisper 生成)
|
||||||
title: 视频标题(可选)
|
title: 视频标题(可选)
|
||||||
title_duration: 标题显示时长(秒)
|
title_duration: 标题显示时长(秒)
|
||||||
|
title_display_mode: 标题显示模式(short/persistent)
|
||||||
fps: 帧率
|
fps: 帧率
|
||||||
enable_subtitles: 是否启用字幕
|
enable_subtitles: 是否启用字幕
|
||||||
on_progress: 进度回调函数
|
on_progress: 进度回调函数
|
||||||
@@ -75,6 +80,7 @@ class RemotionService:
|
|||||||
if title:
|
if title:
|
||||||
cmd.extend(["--title", title])
|
cmd.extend(["--title", title])
|
||||||
cmd.extend(["--titleDuration", str(title_duration)])
|
cmd.extend(["--titleDuration", str(title_duration)])
|
||||||
|
cmd.extend(["--titleDisplayMode", title_display_mode])
|
||||||
|
|
||||||
if subtitle_style:
|
if subtitle_style:
|
||||||
cmd.extend(["--subtitleStyle", json.dumps(subtitle_style, ensure_ascii=False)])
|
cmd.extend(["--subtitleStyle", json.dumps(subtitle_style, ensure_ascii=False)])
|
||||||
@@ -82,6 +88,12 @@ class RemotionService:
|
|||||||
if title_style:
|
if title_style:
|
||||||
cmd.extend(["--titleStyle", json.dumps(title_style, ensure_ascii=False)])
|
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)}")
|
logger.info(f"Running Remotion render: {' '.join(cmd)}")
|
||||||
|
|
||||||
# 在线程池中运行子进程
|
# 在线程池中运行子进程
|
||||||
@@ -95,8 +107,12 @@ class RemotionService:
|
|||||||
bufsize=1
|
bufsize=1
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if process.stdout is None:
|
||||||
|
raise RuntimeError("Remotion process stdout is unavailable")
|
||||||
|
stdout = process.stdout
|
||||||
|
|
||||||
output_lines = []
|
output_lines = []
|
||||||
for line in iter(process.stdout.readline, ''):
|
for line in iter(stdout.readline, ''):
|
||||||
line = line.strip()
|
line = line.strip()
|
||||||
if line:
|
if line:
|
||||||
output_lines.append(line)
|
output_lines.append(line)
|
||||||
|
|||||||
@@ -247,19 +247,67 @@ class WhisperService:
|
|||||||
line_segments = split_segment_to_lines(all_words, max_chars)
|
line_segments = split_segment_to_lines(all_words, max_chars)
|
||||||
all_segments.extend(line_segments)
|
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:
|
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)}), "
|
# 收集 Whisper 逐字时间戳(保留真实语音节奏)
|
||||||
f"Whisper time range: {whisper_first_start:.2f}-{whisper_last_end:.2f}s")
|
whisper_chars = []
|
||||||
# 用 split_word_to_chars 拆分原文
|
for seg in all_segments:
|
||||||
|
whisper_chars.extend(seg.get("words", []))
|
||||||
|
|
||||||
|
# 用原文字符 + Whisper 节奏生成新的时间戳
|
||||||
orig_chars = split_word_to_chars(
|
orig_chars = split_word_to_chars(
|
||||||
original_text.strip(),
|
original_text.strip(),
|
||||||
whisper_first_start,
|
whisper_first_start,
|
||||||
whisper_last_end
|
whisper_last_end
|
||||||
)
|
)
|
||||||
if orig_chars:
|
|
||||||
|
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"]
|
||||||
|
|
||||||
|
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),
|
||||||
|
})
|
||||||
|
|
||||||
|
all_segments = split_segment_to_lines(remapped, max_chars)
|
||||||
|
logger.info(f"Rebuilt {len(all_segments)} subtitle segments (rhythm-mapped)")
|
||||||
|
elif orig_chars:
|
||||||
|
# Whisper 字符不足,退回线性插值
|
||||||
all_segments = split_segment_to_lines(orig_chars, max_chars)
|
all_segments = split_segment_to_lines(orig_chars, max_chars)
|
||||||
logger.info(f"Rebuilt {len(all_segments)} subtitle segments from original text")
|
logger.info(f"Rebuilt {len(all_segments)} subtitle segments (linear fallback)")
|
||||||
|
|
||||||
logger.info(f"Generated {len(all_segments)} subtitle segments")
|
logger.info(f"Generated {len(all_segments)} subtitle segments")
|
||||||
return {"segments": all_segments}
|
return {"segments": all_segments}
|
||||||
|
|||||||
@@ -71,3 +71,18 @@ CREATE TRIGGER users_updated_at
|
|||||||
BEFORE UPDATE ON users
|
BEFORE UPDATE ON users
|
||||||
FOR EACH ROW
|
FOR EACH ROW
|
||||||
EXECUTE FUNCTION update_updated_at();
|
EXECUTE FUNCTION update_updated_at();
|
||||||
|
|
||||||
|
-- 8. 订单表(支付宝付费)
|
||||||
|
CREATE TABLE IF NOT EXISTS orders (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
out_trade_no TEXT UNIQUE NOT NULL,
|
||||||
|
amount DECIMAL(10, 2) NOT NULL DEFAULT 999.00,
|
||||||
|
status TEXT DEFAULT 'pending' CHECK (status IN ('pending', 'paid', 'failed')),
|
||||||
|
trade_no TEXT,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
paid_at TIMESTAMP WITH TIME ZONE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_orders_user_id ON orders(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_orders_out_trade_no ON orders(out_trade_no);
|
||||||
|
|||||||
31
backend/package-lock.json
generated
Normal file
31
backend/package-lock.json
generated
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"name": "backend",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"dependencies": {
|
||||||
|
"qrcode.react": "^4.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/qrcode.react": {
|
||||||
|
"version": "4.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz",
|
||||||
|
"integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/react": {
|
||||||
|
"version": "19.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
|
||||||
|
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
5
backend/package.json
Normal file
5
backend/package.json
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"dependencies": {
|
||||||
|
"qrcode.react": "^4.2.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,6 +29,9 @@ python-jose[cryptography]>=3.3.0
|
|||||||
passlib[bcrypt]>=1.7.4
|
passlib[bcrypt]>=1.7.4
|
||||||
bcrypt==4.0.1
|
bcrypt==4.0.1
|
||||||
|
|
||||||
|
# 支付宝支付
|
||||||
|
python-alipay-sdk>=3.6.0
|
||||||
|
|
||||||
# 字幕对齐
|
# 字幕对齐
|
||||||
faster-whisper>=1.0.0
|
faster-whisper>=1.0.0
|
||||||
|
|
||||||
|
|||||||
10
frontend/package-lock.json
generated
10
frontend/package-lock.json
generated
@@ -15,6 +15,7 @@
|
|||||||
"axios": "^1.13.4",
|
"axios": "^1.13.4",
|
||||||
"lucide-react": "^0.563.0",
|
"lucide-react": "^0.563.0",
|
||||||
"next": "16.1.1",
|
"next": "16.1.1",
|
||||||
|
"qrcode.react": "^4.2.0",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-dom": "19.2.3",
|
"react-dom": "19.2.3",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
@@ -5618,6 +5619,15 @@
|
|||||||
"node": ">=6"
|
"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": {
|
"node_modules/queue-microtask": {
|
||||||
"version": "1.2.3",
|
"version": "1.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
"axios": "^1.13.4",
|
"axios": "^1.13.4",
|
||||||
"lucide-react": "^0.563.0",
|
"lucide-react": "^0.563.0",
|
||||||
"next": "16.1.1",
|
"next": "16.1.1",
|
||||||
|
"qrcode.react": "^4.2.0",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-dom": "19.2.3",
|
"react-dom": "19.2.3",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
|
|||||||
@@ -3,9 +3,11 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { login } from "@/shared/lib/auth";
|
import { login } from "@/shared/lib/auth";
|
||||||
|
import { useAuth } from "@/shared/contexts/AuthContext";
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { setUser } = useAuth();
|
||||||
const [phone, setPhone] = useState('');
|
const [phone, setPhone] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
@@ -25,7 +27,11 @@ export default function LoginPage() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await login(phone, password);
|
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('/');
|
router.push('/');
|
||||||
} else {
|
} else {
|
||||||
setError(result.message || '登录失败');
|
setError(result.message || '登录失败');
|
||||||
|
|||||||
160
frontend/src/app/pay/page.tsx
Normal file
160
frontend/src/app/pay/page.tsx
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Suspense, useState, useEffect, useRef } from 'react';
|
||||||
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
import api from '@/shared/api/axios';
|
||||||
|
|
||||||
|
type PageStatus = 'loading' | 'redirecting' | 'checking' | 'success' | 'error';
|
||||||
|
|
||||||
|
function PayContent() {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const [status, setStatus] = useState<PageStatus>('loading');
|
||||||
|
const [errorMsg, setErrorMsg] = useState('');
|
||||||
|
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const outTradeNo = searchParams.get('out_trade_no');
|
||||||
|
if (outTradeNo) {
|
||||||
|
setStatus('checking');
|
||||||
|
startPolling(outTradeNo);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = sessionStorage.getItem('payment_token');
|
||||||
|
if (!token) {
|
||||||
|
router.replace('/login');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
createOrder(token);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (pollRef.current) clearInterval(pollRef.current);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const createOrder = async (token: string) => {
|
||||||
|
try {
|
||||||
|
const { data } = await api.post('/api/payment/create-order', { payment_token: token });
|
||||||
|
const { pay_url } = data.data;
|
||||||
|
setStatus('redirecting');
|
||||||
|
window.location.href = pay_url;
|
||||||
|
} catch (err: any) {
|
||||||
|
setStatus('error');
|
||||||
|
setErrorMsg(err.response?.data?.message || '创建订单失败,请重新登录');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const startPolling = (tradeNo: string) => {
|
||||||
|
checkStatus(tradeNo);
|
||||||
|
pollRef.current = setInterval(() => checkStatus(tradeNo), 3000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const checkStatus = async (tradeNo: string) => {
|
||||||
|
try {
|
||||||
|
const { data } = await api.get(`/api/payment/status/${tradeNo}`);
|
||||||
|
if (data.data.status === 'paid') {
|
||||||
|
if (pollRef.current) clearInterval(pollRef.current);
|
||||||
|
setStatus('success');
|
||||||
|
sessionStorage.removeItem('payment_token');
|
||||||
|
setTimeout(() => router.replace('/login'), 3000);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore polling errors
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full max-w-md p-8 bg-white/10 backdrop-blur-lg rounded-2xl shadow-2xl border border-white/20">
|
||||||
|
{(status === 'loading' || status === 'redirecting') && (
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="mb-6">
|
||||||
|
<svg className="animate-spin h-12 w-12 mx-auto text-purple-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<p className="text-gray-300">
|
||||||
|
{status === 'loading' ? '正在创建订单...' : '正在跳转到支付宝...'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status === 'checking' && (
|
||||||
|
<div className="text-center">
|
||||||
|
<h1 className="text-2xl font-bold text-white mb-6">支付确认中</h1>
|
||||||
|
<div className="flex items-center justify-center gap-2 text-purple-300 mb-4">
|
||||||
|
<svg className="animate-spin h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
正在确认支付结果...
|
||||||
|
</div>
|
||||||
|
<p className="text-gray-400 text-sm">如果您已完成支付,请稍候</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status === 'success' && (
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="mb-6">
|
||||||
|
<svg className="w-16 h-16 mx-auto text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-bold text-white mb-4">支付成功!</h2>
|
||||||
|
<p className="text-gray-300 mb-2">会员已开通,即将跳转到登录页...</p>
|
||||||
|
<p className="text-gray-500 text-sm">请重新登录即可使用</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status === 'error' && (
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="mb-6">
|
||||||
|
<svg className="w-16 h-16 mx-auto text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-bold text-white mb-4">创建订单失败</h2>
|
||||||
|
<p className="text-red-300 mb-6">{errorMsg}</p>
|
||||||
|
<button
|
||||||
|
onClick={() => router.replace('/login')}
|
||||||
|
className="py-3 px-6 bg-gradient-to-r from-purple-600 to-pink-600 text-white font-semibold rounded-lg"
|
||||||
|
>
|
||||||
|
返回登录
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status === 'checking' && (
|
||||||
|
<div className="mt-6 text-center">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (pollRef.current) clearInterval(pollRef.current);
|
||||||
|
router.replace('/login');
|
||||||
|
}}
|
||||||
|
className="text-purple-300 hover:text-purple-200 text-sm"
|
||||||
|
>
|
||||||
|
返回登录
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PayPage() {
|
||||||
|
return (
|
||||||
|
<div className="min-h-dvh flex items-center justify-center">
|
||||||
|
<Suspense fallback={
|
||||||
|
<div className="w-full max-w-md p-8 bg-white/10 backdrop-blur-lg rounded-2xl shadow-2xl border border-white/20 text-center">
|
||||||
|
<svg className="animate-spin h-12 w-12 mx-auto text-purple-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
}>
|
||||||
|
<PayContent />
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -61,7 +61,7 @@ export default function RegisterPage() {
|
|||||||
</div>
|
</div>
|
||||||
<h2 className="text-2xl font-bold text-white mb-4">注册成功!</h2>
|
<h2 className="text-2xl font-bold text-white mb-4">注册成功!</h2>
|
||||||
<p className="text-gray-300 mb-6">
|
<p className="text-gray-300 mb-6">
|
||||||
您的账号已创建,请等待管理员审核激活后即可登录。
|
注册成功!请返回登录页,登录后完成付费即可开通。
|
||||||
</p>
|
</p>
|
||||||
<a
|
<a
|
||||||
href="/login"
|
href="/login"
|
||||||
|
|||||||
@@ -106,6 +106,10 @@ export default function AccountSettingsDropdown() {
|
|||||||
{/* 下拉菜单 */}
|
{/* 下拉菜单 */}
|
||||||
{isOpen && (
|
{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="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="px-3 py-2 border-b border-white/10 text-center">
|
||||||
<div className="text-xs text-gray-400">账户有效期</div>
|
<div className="text-xs text-gray-400">账户有效期</div>
|
||||||
@@ -188,6 +192,7 @@ export default function AccountSettingsDropdown() {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
setShowPasswordModal(false);
|
setShowPasswordModal(false);
|
||||||
setError('');
|
setError('');
|
||||||
|
setSuccess('');
|
||||||
setOldPassword('');
|
setOldPassword('');
|
||||||
setNewPassword('');
|
setNewPassword('');
|
||||||
setConfirmPassword('');
|
setConfirmPassword('');
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
resolveBgmUrl,
|
resolveBgmUrl,
|
||||||
resolveMediaUrl,
|
resolveMediaUrl,
|
||||||
} from "@/shared/lib/media";
|
} 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 { useTitleInput } from "@/shared/hooks/useTitleInput";
|
||||||
import { useAuth } from "@/shared/contexts/AuthContext";
|
import { useAuth } from "@/shared/contexts/AuthContext";
|
||||||
import { useTask } from "@/shared/contexts/TaskContext";
|
import { useTask } from "@/shared/contexts/TaskContext";
|
||||||
@@ -87,6 +87,8 @@ const LANG_TO_LOCALE: Record<string, string> = {
|
|||||||
"Português": "pt-BR",
|
"Português": "pt-BR",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const DEFAULT_SHORT_TITLE_DURATION = 4;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const scrollContainerToItem = (container: HTMLDivElement, item: HTMLDivElement) => {
|
const scrollContainerToItem = (container: HTMLDivElement, item: HTMLDivElement) => {
|
||||||
@@ -149,11 +151,19 @@ export const useHomeController = () => {
|
|||||||
const [subtitleSizeLocked, setSubtitleSizeLocked] = useState<boolean>(false);
|
const [subtitleSizeLocked, setSubtitleSizeLocked] = useState<boolean>(false);
|
||||||
const [titleSizeLocked, setTitleSizeLocked] = useState<boolean>(false);
|
const [titleSizeLocked, setTitleSizeLocked] = useState<boolean>(false);
|
||||||
const [titleTopMargin, setTitleTopMargin] = useState<number>(62);
|
const [titleTopMargin, setTitleTopMargin] = useState<number>(62);
|
||||||
|
const [titleDisplayMode, setTitleDisplayMode] = useState<"short" | "persistent">("short");
|
||||||
const [subtitleBottomMargin, setSubtitleBottomMargin] = useState<number>(80);
|
const [subtitleBottomMargin, setSubtitleBottomMargin] = useState<number>(80);
|
||||||
const [outputAspectRatio, setOutputAspectRatio] = useState<"9:16" | "16:9">("9:16");
|
const [outputAspectRatio, setOutputAspectRatio] = useState<"9:16" | "16:9">("9:16");
|
||||||
const [showStylePreview, setShowStylePreview] = useState<boolean>(false);
|
const [showStylePreview, setShowStylePreview] = useState<boolean>(false);
|
||||||
const [materialDimensions, setMaterialDimensions] = useState<{ width: number; height: number } | null>(null);
|
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>("");
|
const [selectedBgmId, setSelectedBgmId] = useState<string>("");
|
||||||
@@ -427,6 +437,8 @@ export const useHomeController = () => {
|
|||||||
setText,
|
setText,
|
||||||
videoTitle,
|
videoTitle,
|
||||||
setVideoTitle,
|
setVideoTitle,
|
||||||
|
videoSecondaryTitle,
|
||||||
|
setVideoSecondaryTitle,
|
||||||
ttsMode,
|
ttsMode,
|
||||||
setTtsMode,
|
setTtsMode,
|
||||||
voice,
|
voice,
|
||||||
@@ -439,14 +451,23 @@ export const useHomeController = () => {
|
|||||||
setSelectedSubtitleStyleId,
|
setSelectedSubtitleStyleId,
|
||||||
selectedTitleStyleId,
|
selectedTitleStyleId,
|
||||||
setSelectedTitleStyleId,
|
setSelectedTitleStyleId,
|
||||||
|
selectedSecondaryTitleStyleId,
|
||||||
|
setSelectedSecondaryTitleStyleId,
|
||||||
subtitleFontSize,
|
subtitleFontSize,
|
||||||
setSubtitleFontSize,
|
setSubtitleFontSize,
|
||||||
titleFontSize,
|
titleFontSize,
|
||||||
setTitleFontSize,
|
setTitleFontSize,
|
||||||
|
secondaryTitleFontSize,
|
||||||
|
setSecondaryTitleFontSize,
|
||||||
setSubtitleSizeLocked,
|
setSubtitleSizeLocked,
|
||||||
setTitleSizeLocked,
|
setTitleSizeLocked,
|
||||||
|
setSecondaryTitleSizeLocked,
|
||||||
titleTopMargin,
|
titleTopMargin,
|
||||||
setTitleTopMargin,
|
setTitleTopMargin,
|
||||||
|
secondaryTitleTopMargin,
|
||||||
|
setSecondaryTitleTopMargin,
|
||||||
|
titleDisplayMode,
|
||||||
|
setTitleDisplayMode,
|
||||||
subtitleBottomMargin,
|
subtitleBottomMargin,
|
||||||
setSubtitleBottomMargin,
|
setSubtitleBottomMargin,
|
||||||
outputAspectRatio,
|
outputAspectRatio,
|
||||||
@@ -486,6 +507,12 @@ export const useHomeController = () => {
|
|||||||
onCommit: syncTitleToPublish,
|
onCommit: syncTitleToPublish,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const secondaryTitleInput = useTitleInput({
|
||||||
|
value: videoSecondaryTitle,
|
||||||
|
onChange: setVideoSecondaryTitle,
|
||||||
|
maxLength: SECONDARY_TITLE_MAX_LENGTH,
|
||||||
|
});
|
||||||
|
|
||||||
// 加载素材列表和历史视频
|
// 加载素材列表和历史视频
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isAuthLoading) return;
|
if (isAuthLoading) return;
|
||||||
@@ -577,11 +604,32 @@ export const useHomeController = () => {
|
|||||||
}
|
}
|
||||||
}, [titleStyles, selectedTitleStyleId, titleSizeLocked]);
|
}, [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 中)
|
// 移除重复的 BGM 持久化恢复逻辑 (已统一移动到 useHomePersistence 中)
|
||||||
// useEffect(() => { ... })
|
// useEffect(() => { ... })
|
||||||
|
|
||||||
|
// 时间门控:页面加载后 1 秒内禁止所有列表自动滚动效果
|
||||||
|
// 防止持久化恢复 + 异步数据加载触发 scrollIntoView 导致移动端页面跳动
|
||||||
|
const scrollEffectsEnabled = useRef(false);
|
||||||
useEffect(() => {
|
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 container = bgmListContainerRef.current;
|
||||||
const target = bgmItemRefs.current[selectedBgmId];
|
const target = bgmItemRefs.current[selectedBgmId];
|
||||||
if (container && target) {
|
if (container && target) {
|
||||||
@@ -589,16 +637,10 @@ export const useHomeController = () => {
|
|||||||
}
|
}
|
||||||
}, [selectedBgmId, bgmList]);
|
}, [selectedBgmId, bgmList]);
|
||||||
|
|
||||||
// 素材列表滚动:跳过首次恢复,仅用户主动操作时滚动
|
// 素材列表滚动
|
||||||
const materialScrollReady = useRef(false);
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const firstSelected = selectedMaterials[0];
|
const firstSelected = selectedMaterials[0];
|
||||||
if (!firstSelected) return;
|
if (!firstSelected || !scrollEffectsEnabled.current) return;
|
||||||
if (!materialScrollReady.current) {
|
|
||||||
// 首次有选中素材时标记就绪,但不滚动(避免刷新后整页跳动)
|
|
||||||
materialScrollReady.current = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const target = materialItemRefs.current[firstSelected];
|
const target = materialItemRefs.current[firstSelected];
|
||||||
if (target) {
|
if (target) {
|
||||||
target.scrollIntoView({ block: "nearest", behavior: "smooth" });
|
target.scrollIntoView({ block: "nearest", behavior: "smooth" });
|
||||||
@@ -623,14 +665,9 @@ export const useHomeController = () => {
|
|||||||
}
|
}
|
||||||
}, [isRestored, bgmList, selectedBgmId, enableBgm, setSelectedBgmId]);
|
}, [isRestored, bgmList, selectedBgmId, enableBgm, setSelectedBgmId]);
|
||||||
|
|
||||||
const videoScrollReady = useRef(false);
|
// 视频列表滚动
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedVideoId) return;
|
if (!selectedVideoId || !scrollEffectsEnabled.current) return;
|
||||||
if (!videoScrollReady.current) {
|
|
||||||
videoScrollReady.current = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const target = videoItemRefs.current[selectedVideoId];
|
const target = videoItemRefs.current[selectedVideoId];
|
||||||
if (target) {
|
if (target) {
|
||||||
target.scrollIntoView({ block: "nearest", behavior: "smooth" });
|
target.scrollIntoView({ block: "nearest", behavior: "smooth" });
|
||||||
@@ -736,7 +773,7 @@ export const useHomeController = () => {
|
|||||||
|
|
||||||
setIsGeneratingMeta(true);
|
setIsGeneratingMeta(true);
|
||||||
try {
|
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",
|
"/api/ai/generate-meta",
|
||||||
{ text: text.trim() }
|
{ text: text.trim() }
|
||||||
);
|
);
|
||||||
@@ -746,6 +783,10 @@ export const useHomeController = () => {
|
|||||||
const nextTitle = clampTitle(payload.title || "");
|
const nextTitle = clampTitle(payload.title || "");
|
||||||
titleInput.commitValue(nextTitle);
|
titleInput.commitValue(nextTitle);
|
||||||
|
|
||||||
|
// 更新副标题
|
||||||
|
const nextSecondaryTitle = clampSecondaryTitle(payload.secondary_title || "");
|
||||||
|
secondaryTitleInput.commitValue(nextSecondaryTitle);
|
||||||
|
|
||||||
// 同步到发布页 localStorage
|
// 同步到发布页 localStorage
|
||||||
localStorage.setItem(`vigent_${storageKey}_publish_tags`, JSON.stringify(payload.tags || []));
|
localStorage.setItem(`vigent_${storageKey}_publish_tags`, JSON.stringify(payload.tags || []));
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
@@ -937,10 +978,28 @@ export const useHomeController = () => {
|
|||||||
payload.title_font_size = Math.round(titleFontSize);
|
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()) {
|
if (videoTitle.trim()) {
|
||||||
payload.title_top_margin = Math.round(titleTopMargin);
|
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);
|
payload.subtitle_bottom_margin = Math.round(subtitleBottomMargin);
|
||||||
|
|
||||||
if (enableBgm && selectedBgmId) {
|
if (enableBgm && selectedBgmId) {
|
||||||
@@ -1040,6 +1099,15 @@ export const useHomeController = () => {
|
|||||||
titleFontSize,
|
titleFontSize,
|
||||||
setTitleFontSize,
|
setTitleFontSize,
|
||||||
setTitleSizeLocked,
|
setTitleSizeLocked,
|
||||||
|
videoSecondaryTitle,
|
||||||
|
secondaryTitleInput,
|
||||||
|
selectedSecondaryTitleStyleId,
|
||||||
|
setSelectedSecondaryTitleStyleId,
|
||||||
|
secondaryTitleFontSize,
|
||||||
|
setSecondaryTitleFontSize,
|
||||||
|
setSecondaryTitleSizeLocked,
|
||||||
|
secondaryTitleTopMargin,
|
||||||
|
setSecondaryTitleTopMargin,
|
||||||
subtitleStyles,
|
subtitleStyles,
|
||||||
selectedSubtitleStyleId,
|
selectedSubtitleStyleId,
|
||||||
setSelectedSubtitleStyleId,
|
setSelectedSubtitleStyleId,
|
||||||
@@ -1048,6 +1116,8 @@ export const useHomeController = () => {
|
|||||||
setSubtitleSizeLocked,
|
setSubtitleSizeLocked,
|
||||||
titleTopMargin,
|
titleTopMargin,
|
||||||
setTitleTopMargin,
|
setTitleTopMargin,
|
||||||
|
titleDisplayMode,
|
||||||
|
setTitleDisplayMode,
|
||||||
subtitleBottomMargin,
|
subtitleBottomMargin,
|
||||||
setSubtitleBottomMargin,
|
setSubtitleBottomMargin,
|
||||||
outputAspectRatio,
|
outputAspectRatio,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { clampTitle } from "@/shared/lib/title";
|
import { clampTitle, clampSecondaryTitle } from "@/shared/lib/title";
|
||||||
|
|
||||||
interface RefAudio {
|
interface RefAudio {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -17,6 +17,8 @@ interface UseHomePersistenceOptions {
|
|||||||
setText: React.Dispatch<React.SetStateAction<string>>;
|
setText: React.Dispatch<React.SetStateAction<string>>;
|
||||||
videoTitle: string;
|
videoTitle: string;
|
||||||
setVideoTitle: React.Dispatch<React.SetStateAction<string>>;
|
setVideoTitle: React.Dispatch<React.SetStateAction<string>>;
|
||||||
|
videoSecondaryTitle: string;
|
||||||
|
setVideoSecondaryTitle: React.Dispatch<React.SetStateAction<string>>;
|
||||||
ttsMode: 'edgetts' | 'voiceclone';
|
ttsMode: 'edgetts' | 'voiceclone';
|
||||||
setTtsMode: React.Dispatch<React.SetStateAction<'edgetts' | 'voiceclone'>>;
|
setTtsMode: React.Dispatch<React.SetStateAction<'edgetts' | 'voiceclone'>>;
|
||||||
voice: string;
|
voice: string;
|
||||||
@@ -29,14 +31,23 @@ interface UseHomePersistenceOptions {
|
|||||||
setSelectedSubtitleStyleId: React.Dispatch<React.SetStateAction<string>>;
|
setSelectedSubtitleStyleId: React.Dispatch<React.SetStateAction<string>>;
|
||||||
selectedTitleStyleId: string;
|
selectedTitleStyleId: string;
|
||||||
setSelectedTitleStyleId: React.Dispatch<React.SetStateAction<string>>;
|
setSelectedTitleStyleId: React.Dispatch<React.SetStateAction<string>>;
|
||||||
|
selectedSecondaryTitleStyleId: string;
|
||||||
|
setSelectedSecondaryTitleStyleId: React.Dispatch<React.SetStateAction<string>>;
|
||||||
subtitleFontSize: number;
|
subtitleFontSize: number;
|
||||||
setSubtitleFontSize: React.Dispatch<React.SetStateAction<number>>;
|
setSubtitleFontSize: React.Dispatch<React.SetStateAction<number>>;
|
||||||
titleFontSize: number;
|
titleFontSize: number;
|
||||||
setTitleFontSize: React.Dispatch<React.SetStateAction<number>>;
|
setTitleFontSize: React.Dispatch<React.SetStateAction<number>>;
|
||||||
|
secondaryTitleFontSize: number;
|
||||||
|
setSecondaryTitleFontSize: React.Dispatch<React.SetStateAction<number>>;
|
||||||
setSubtitleSizeLocked: React.Dispatch<React.SetStateAction<boolean>>;
|
setSubtitleSizeLocked: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
setTitleSizeLocked: React.Dispatch<React.SetStateAction<boolean>>;
|
setTitleSizeLocked: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
setSecondaryTitleSizeLocked: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
titleTopMargin: number;
|
titleTopMargin: number;
|
||||||
setTitleTopMargin: React.Dispatch<React.SetStateAction<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;
|
subtitleBottomMargin: number;
|
||||||
setSubtitleBottomMargin: React.Dispatch<React.SetStateAction<number>>;
|
setSubtitleBottomMargin: React.Dispatch<React.SetStateAction<number>>;
|
||||||
outputAspectRatio: '9:16' | '16:9';
|
outputAspectRatio: '9:16' | '16:9';
|
||||||
@@ -63,6 +74,8 @@ export const useHomePersistence = ({
|
|||||||
setText,
|
setText,
|
||||||
videoTitle,
|
videoTitle,
|
||||||
setVideoTitle,
|
setVideoTitle,
|
||||||
|
videoSecondaryTitle,
|
||||||
|
setVideoSecondaryTitle,
|
||||||
ttsMode,
|
ttsMode,
|
||||||
setTtsMode,
|
setTtsMode,
|
||||||
voice,
|
voice,
|
||||||
@@ -75,14 +88,23 @@ export const useHomePersistence = ({
|
|||||||
setSelectedSubtitleStyleId,
|
setSelectedSubtitleStyleId,
|
||||||
selectedTitleStyleId,
|
selectedTitleStyleId,
|
||||||
setSelectedTitleStyleId,
|
setSelectedTitleStyleId,
|
||||||
|
selectedSecondaryTitleStyleId,
|
||||||
|
setSelectedSecondaryTitleStyleId,
|
||||||
subtitleFontSize,
|
subtitleFontSize,
|
||||||
setSubtitleFontSize,
|
setSubtitleFontSize,
|
||||||
titleFontSize,
|
titleFontSize,
|
||||||
setTitleFontSize,
|
setTitleFontSize,
|
||||||
|
secondaryTitleFontSize,
|
||||||
|
setSecondaryTitleFontSize,
|
||||||
setSubtitleSizeLocked,
|
setSubtitleSizeLocked,
|
||||||
setTitleSizeLocked,
|
setTitleSizeLocked,
|
||||||
|
setSecondaryTitleSizeLocked,
|
||||||
titleTopMargin,
|
titleTopMargin,
|
||||||
setTitleTopMargin,
|
setTitleTopMargin,
|
||||||
|
secondaryTitleTopMargin,
|
||||||
|
setSecondaryTitleTopMargin,
|
||||||
|
titleDisplayMode,
|
||||||
|
setTitleDisplayMode,
|
||||||
subtitleBottomMargin,
|
subtitleBottomMargin,
|
||||||
setSubtitleBottomMargin,
|
setSubtitleBottomMargin,
|
||||||
outputAspectRatio,
|
outputAspectRatio,
|
||||||
@@ -108,26 +130,32 @@ export const useHomePersistence = ({
|
|||||||
|
|
||||||
const savedText = localStorage.getItem(`vigent_${storageKey}_text`);
|
const savedText = localStorage.getItem(`vigent_${storageKey}_text`);
|
||||||
const savedTitle = localStorage.getItem(`vigent_${storageKey}_title`);
|
const savedTitle = localStorage.getItem(`vigent_${storageKey}_title`);
|
||||||
|
const savedSecondaryTitle = localStorage.getItem(`vigent_${storageKey}_secondaryTitle`);
|
||||||
const savedTtsMode = localStorage.getItem(`vigent_${storageKey}_ttsMode`);
|
const savedTtsMode = localStorage.getItem(`vigent_${storageKey}_ttsMode`);
|
||||||
const savedVoice = localStorage.getItem(`vigent_${storageKey}_voice`);
|
const savedVoice = localStorage.getItem(`vigent_${storageKey}_voice`);
|
||||||
const savedTextLang = localStorage.getItem(`vigent_${storageKey}_textLang`);
|
const savedTextLang = localStorage.getItem(`vigent_${storageKey}_textLang`);
|
||||||
const savedMaterial = localStorage.getItem(`vigent_${storageKey}_material`);
|
const savedMaterial = localStorage.getItem(`vigent_${storageKey}_material`);
|
||||||
const savedSubtitleStyle = localStorage.getItem(`vigent_${storageKey}_subtitleStyle`);
|
const savedSubtitleStyle = localStorage.getItem(`vigent_${storageKey}_subtitleStyle`);
|
||||||
const savedTitleStyle = localStorage.getItem(`vigent_${storageKey}_titleStyle`);
|
const savedTitleStyle = localStorage.getItem(`vigent_${storageKey}_titleStyle`);
|
||||||
|
const savedSecondaryTitleStyle = localStorage.getItem(`vigent_${storageKey}_secondaryTitleStyle`);
|
||||||
const savedSubtitleFontSize = localStorage.getItem(`vigent_${storageKey}_subtitleFontSize`);
|
const savedSubtitleFontSize = localStorage.getItem(`vigent_${storageKey}_subtitleFontSize`);
|
||||||
const savedTitleFontSize = localStorage.getItem(`vigent_${storageKey}_titleFontSize`);
|
const savedTitleFontSize = localStorage.getItem(`vigent_${storageKey}_titleFontSize`);
|
||||||
|
const savedSecondaryTitleFontSize = localStorage.getItem(`vigent_${storageKey}_secondaryTitleFontSize`);
|
||||||
const savedBgmId = localStorage.getItem(`vigent_${storageKey}_bgmId`);
|
const savedBgmId = localStorage.getItem(`vigent_${storageKey}_bgmId`);
|
||||||
const savedSelectedVideoId = localStorage.getItem(`vigent_${storageKey}_selectedVideoId`);
|
const savedSelectedVideoId = localStorage.getItem(`vigent_${storageKey}_selectedVideoId`);
|
||||||
const savedSelectedAudioId = localStorage.getItem(`vigent_${storageKey}_selectedAudioId`);
|
const savedSelectedAudioId = localStorage.getItem(`vigent_${storageKey}_selectedAudioId`);
|
||||||
const savedBgmVolume = localStorage.getItem(`vigent_${storageKey}_bgmVolume`);
|
const savedBgmVolume = localStorage.getItem(`vigent_${storageKey}_bgmVolume`);
|
||||||
const savedEnableBgm = localStorage.getItem(`vigent_${storageKey}_enableBgm`);
|
const savedEnableBgm = localStorage.getItem(`vigent_${storageKey}_enableBgm`);
|
||||||
const savedTitleTopMargin = localStorage.getItem(`vigent_${storageKey}_titleTopMargin`);
|
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 savedSubtitleBottomMargin = localStorage.getItem(`vigent_${storageKey}_subtitleBottomMargin`);
|
||||||
const savedOutputAspectRatio = localStorage.getItem(`vigent_${storageKey}_outputAspectRatio`);
|
const savedOutputAspectRatio = localStorage.getItem(`vigent_${storageKey}_outputAspectRatio`);
|
||||||
const savedSpeed = localStorage.getItem(`vigent_${storageKey}_speed`);
|
const savedSpeed = localStorage.getItem(`vigent_${storageKey}_speed`);
|
||||||
|
|
||||||
setText(savedText || "大家好,欢迎来到我的频道,今天给大家分享一些有趣的内容。");
|
setText(savedText || "大家好,欢迎来到我的频道,今天给大家分享一些有趣的内容。");
|
||||||
setVideoTitle(savedTitle ? clampTitle(savedTitle) : "");
|
setVideoTitle(savedTitle ? clampTitle(savedTitle) : "");
|
||||||
|
setVideoSecondaryTitle(savedSecondaryTitle ? clampSecondaryTitle(savedSecondaryTitle) : "");
|
||||||
setTtsMode((savedTtsMode as 'edgetts' | 'voiceclone') || 'edgetts');
|
setTtsMode((savedTtsMode as 'edgetts' | 'voiceclone') || 'edgetts');
|
||||||
setVoice(savedVoice || "zh-CN-YunxiNeural");
|
setVoice(savedVoice || "zh-CN-YunxiNeural");
|
||||||
if (savedTextLang) setTextLang(savedTextLang);
|
if (savedTextLang) setTextLang(savedTextLang);
|
||||||
@@ -147,6 +175,7 @@ export const useHomePersistence = ({
|
|||||||
}
|
}
|
||||||
if (savedSubtitleStyle) setSelectedSubtitleStyleId(savedSubtitleStyle);
|
if (savedSubtitleStyle) setSelectedSubtitleStyleId(savedSubtitleStyle);
|
||||||
if (savedTitleStyle) setSelectedTitleStyleId(savedTitleStyle);
|
if (savedTitleStyle) setSelectedTitleStyleId(savedTitleStyle);
|
||||||
|
if (savedSecondaryTitleStyle) setSelectedSecondaryTitleStyleId(savedSecondaryTitleStyle);
|
||||||
|
|
||||||
if (savedSubtitleFontSize) {
|
if (savedSubtitleFontSize) {
|
||||||
const parsed = parseInt(savedSubtitleFontSize, 10);
|
const parsed = parseInt(savedSubtitleFontSize, 10);
|
||||||
@@ -164,6 +193,14 @@ export const useHomePersistence = ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (savedSecondaryTitleFontSize) {
|
||||||
|
const parsed = parseInt(savedSecondaryTitleFontSize, 10);
|
||||||
|
if (!Number.isNaN(parsed)) {
|
||||||
|
setSecondaryTitleFontSize(parsed);
|
||||||
|
setSecondaryTitleSizeLocked(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (savedBgmId) setSelectedBgmId(savedBgmId);
|
if (savedBgmId) setSelectedBgmId(savedBgmId);
|
||||||
if (savedBgmVolume) setBgmVolume(parseFloat(savedBgmVolume));
|
if (savedBgmVolume) setBgmVolume(parseFloat(savedBgmVolume));
|
||||||
if (savedEnableBgm !== null) setEnableBgm(savedEnableBgm === 'true');
|
if (savedEnableBgm !== null) setEnableBgm(savedEnableBgm === 'true');
|
||||||
@@ -174,6 +211,13 @@ export const useHomePersistence = ({
|
|||||||
const parsed = parseInt(savedTitleTopMargin, 10);
|
const parsed = parseInt(savedTitleTopMargin, 10);
|
||||||
if (!Number.isNaN(parsed)) setTitleTopMargin(parsed);
|
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) {
|
if (savedSubtitleBottomMargin) {
|
||||||
const parsed = parseInt(savedSubtitleBottomMargin, 10);
|
const parsed = parseInt(savedSubtitleBottomMargin, 10);
|
||||||
if (!Number.isNaN(parsed)) setSubtitleBottomMargin(parsed);
|
if (!Number.isNaN(parsed)) setSubtitleBottomMargin(parsed);
|
||||||
@@ -198,6 +242,7 @@ export const useHomePersistence = ({
|
|||||||
setSelectedMaterials,
|
setSelectedMaterials,
|
||||||
setSelectedSubtitleStyleId,
|
setSelectedSubtitleStyleId,
|
||||||
setSelectedTitleStyleId,
|
setSelectedTitleStyleId,
|
||||||
|
setSelectedSecondaryTitleStyleId,
|
||||||
setSelectedVideoId,
|
setSelectedVideoId,
|
||||||
setSelectedAudioId,
|
setSelectedAudioId,
|
||||||
setSpeed,
|
setSpeed,
|
||||||
@@ -207,11 +252,16 @@ export const useHomePersistence = ({
|
|||||||
setTextLang,
|
setTextLang,
|
||||||
setTitleFontSize,
|
setTitleFontSize,
|
||||||
setTitleSizeLocked,
|
setTitleSizeLocked,
|
||||||
|
setSecondaryTitleFontSize,
|
||||||
|
setSecondaryTitleSizeLocked,
|
||||||
setTitleTopMargin,
|
setTitleTopMargin,
|
||||||
|
setSecondaryTitleTopMargin,
|
||||||
|
setTitleDisplayMode,
|
||||||
setSubtitleBottomMargin,
|
setSubtitleBottomMargin,
|
||||||
setOutputAspectRatio,
|
setOutputAspectRatio,
|
||||||
setTtsMode,
|
setTtsMode,
|
||||||
setVideoTitle,
|
setVideoTitle,
|
||||||
|
setVideoSecondaryTitle,
|
||||||
setVoice,
|
setVoice,
|
||||||
storageKey,
|
storageKey,
|
||||||
]);
|
]);
|
||||||
@@ -232,6 +282,14 @@ export const useHomePersistence = ({
|
|||||||
return () => clearTimeout(timeout);
|
return () => clearTimeout(timeout);
|
||||||
}, [videoTitle, storageKey, isRestored]);
|
}, [videoTitle, storageKey, isRestored]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isRestored) return;
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
localStorage.setItem(`vigent_${storageKey}_secondaryTitle`, videoSecondaryTitle);
|
||||||
|
}, 300);
|
||||||
|
return () => clearTimeout(timeout);
|
||||||
|
}, [videoSecondaryTitle, storageKey, isRestored]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isRestored) localStorage.setItem(`vigent_${storageKey}_ttsMode`, ttsMode);
|
if (isRestored) localStorage.setItem(`vigent_${storageKey}_ttsMode`, ttsMode);
|
||||||
}, [ttsMode, storageKey, isRestored]);
|
}, [ttsMode, storageKey, isRestored]);
|
||||||
@@ -262,6 +320,12 @@ export const useHomePersistence = ({
|
|||||||
}
|
}
|
||||||
}, [selectedTitleStyleId, storageKey, isRestored]);
|
}, [selectedTitleStyleId, storageKey, isRestored]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isRestored && selectedSecondaryTitleStyleId) {
|
||||||
|
localStorage.setItem(`vigent_${storageKey}_secondaryTitleStyle`, selectedSecondaryTitleStyleId);
|
||||||
|
}
|
||||||
|
}, [selectedSecondaryTitleStyleId, storageKey, isRestored]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isRestored) {
|
if (isRestored) {
|
||||||
localStorage.setItem(`vigent_${storageKey}_subtitleFontSize`, String(subtitleFontSize));
|
localStorage.setItem(`vigent_${storageKey}_subtitleFontSize`, String(subtitleFontSize));
|
||||||
@@ -274,12 +338,30 @@ export const useHomePersistence = ({
|
|||||||
}
|
}
|
||||||
}, [titleFontSize, storageKey, isRestored]);
|
}, [titleFontSize, storageKey, isRestored]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isRestored) {
|
||||||
|
localStorage.setItem(`vigent_${storageKey}_secondaryTitleFontSize`, String(secondaryTitleFontSize));
|
||||||
|
}
|
||||||
|
}, [secondaryTitleFontSize, storageKey, isRestored]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isRestored) {
|
if (isRestored) {
|
||||||
localStorage.setItem(`vigent_${storageKey}_titleTopMargin`, String(titleTopMargin));
|
localStorage.setItem(`vigent_${storageKey}_titleTopMargin`, String(titleTopMargin));
|
||||||
}
|
}
|
||||||
}, [titleTopMargin, storageKey, isRestored]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (isRestored) {
|
if (isRestored) {
|
||||||
localStorage.setItem(`vigent_${storageKey}_subtitleBottomMargin`, String(subtitleBottomMargin));
|
localStorage.setItem(`vigent_${storageKey}_subtitleBottomMargin`, String(subtitleBottomMargin));
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export function BgmPanel({
|
|||||||
return (
|
return (
|
||||||
<div className="bg-white/5 rounded-2xl p-6 border border-white/10 backdrop-blur-sm">
|
<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">
|
<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">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={onRefresh}
|
onClick={onRefresh}
|
||||||
|
|||||||
@@ -213,7 +213,7 @@ export function ClipTrimmer({
|
|||||||
{/* Custom range track */}
|
{/* Custom range track */}
|
||||||
<div
|
<div
|
||||||
ref={trackRef}
|
ref={trackRef}
|
||||||
className="relative h-8 cursor-pointer select-none touch-none"
|
className="relative h-10 cursor-pointer select-none touch-none"
|
||||||
onPointerMove={handleTrackPointerMove}
|
onPointerMove={handleTrackPointerMove}
|
||||||
onPointerUp={handleTrackPointerUp}
|
onPointerUp={handleTrackPointerUp}
|
||||||
onPointerLeave={handleTrackPointerUp}
|
onPointerLeave={handleTrackPointerUp}
|
||||||
@@ -242,7 +242,7 @@ export function ClipTrimmer({
|
|||||||
{/* Start thumb */}
|
{/* Start thumb */}
|
||||||
<div
|
<div
|
||||||
onPointerDown={(e) => handleThumbPointerDown("start", e)}
|
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}%` }}
|
style={{ left: `${startPct}%` }}
|
||||||
title={`起点: ${formatSec(sourceStart)}`}
|
title={`起点: ${formatSec(sourceStart)}`}
|
||||||
/>
|
/>
|
||||||
@@ -250,7 +250,7 @@ export function ClipTrimmer({
|
|||||||
{/* End thumb */}
|
{/* End thumb */}
|
||||||
<div
|
<div
|
||||||
onPointerDown={(e) => handleThumbPointerDown("end", e)}
|
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}%` }}
|
style={{ left: `${endPct}%` }}
|
||||||
title={`终点: ${formatSec(effectiveEnd)}`}
|
title={`终点: ${formatSec(effectiveEnd)}`}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -35,9 +35,13 @@ interface TitleStyleOption {
|
|||||||
interface FloatingStylePreviewProps {
|
interface FloatingStylePreviewProps {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
videoTitle: string;
|
videoTitle: string;
|
||||||
|
videoSecondaryTitle: string;
|
||||||
titleStyles: TitleStyleOption[];
|
titleStyles: TitleStyleOption[];
|
||||||
selectedTitleStyleId: string;
|
selectedTitleStyleId: string;
|
||||||
titleFontSize: number;
|
titleFontSize: number;
|
||||||
|
selectedSecondaryTitleStyleId: string;
|
||||||
|
secondaryTitleFontSize: number;
|
||||||
|
secondaryTitleTopMargin: number;
|
||||||
subtitleStyles: SubtitleStyleOption[];
|
subtitleStyles: SubtitleStyleOption[];
|
||||||
selectedSubtitleStyleId: string;
|
selectedSubtitleStyleId: string;
|
||||||
subtitleFontSize: number;
|
subtitleFontSize: number;
|
||||||
@@ -52,13 +56,18 @@ interface FloatingStylePreviewProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const DESKTOP_WIDTH = 280;
|
const DESKTOP_WIDTH = 280;
|
||||||
|
const MOBILE_WIDTH = 160;
|
||||||
|
|
||||||
export function FloatingStylePreview({
|
export function FloatingStylePreview({
|
||||||
onClose,
|
onClose,
|
||||||
videoTitle,
|
videoTitle,
|
||||||
|
videoSecondaryTitle,
|
||||||
titleStyles,
|
titleStyles,
|
||||||
selectedTitleStyleId,
|
selectedTitleStyleId,
|
||||||
titleFontSize,
|
titleFontSize,
|
||||||
|
selectedSecondaryTitleStyleId,
|
||||||
|
secondaryTitleFontSize,
|
||||||
|
secondaryTitleTopMargin,
|
||||||
subtitleStyles,
|
subtitleStyles,
|
||||||
selectedSubtitleStyleId,
|
selectedSubtitleStyleId,
|
||||||
subtitleFontSize,
|
subtitleFontSize,
|
||||||
@@ -72,9 +81,7 @@ export function FloatingStylePreview({
|
|||||||
previewBaseHeight,
|
previewBaseHeight,
|
||||||
}: FloatingStylePreviewProps) {
|
}: FloatingStylePreviewProps) {
|
||||||
const isMobile = typeof window !== "undefined" && window.innerWidth < 640;
|
const isMobile = typeof window !== "undefined" && window.innerWidth < 640;
|
||||||
const windowWidth = isMobile
|
const windowWidth = isMobile ? MOBILE_WIDTH : DESKTOP_WIDTH;
|
||||||
? Math.min(window.innerWidth - 32, 360)
|
|
||||||
: DESKTOP_WIDTH;
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
@@ -126,15 +133,32 @@ export function FloatingStylePreview({
|
|||||||
const scaledTitleTopMargin = Math.max(0, Math.round(titleTopMargin * responsiveScale));
|
const scaledTitleTopMargin = Math.max(0, Math.round(titleTopMargin * responsiveScale));
|
||||||
const scaledSubtitleBottomMargin = Math.max(0, Math.round(subtitleBottomMargin * 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 = (
|
const content = (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: "fixed",
|
position: "fixed",
|
||||||
left: "16px",
|
...(isMobile
|
||||||
top: "16px",
|
? { right: "12px", bottom: "12px" }
|
||||||
|
: { left: "16px", top: "16px" }),
|
||||||
width: `${windowWidth}px`,
|
width: `${windowWidth}px`,
|
||||||
zIndex: 150,
|
zIndex: 150,
|
||||||
maxHeight: "calc(100dvh - 32px)",
|
maxHeight: isMobile ? "calc(50dvh)" : "calc(100dvh - 32px)",
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
}}
|
}}
|
||||||
className="rounded-xl border border-white/20 bg-gray-900/95 backdrop-blur-md shadow-2xl"
|
className="rounded-xl border border-white/20 bg-gray-900/95 backdrop-blur-md shadow-2xl"
|
||||||
@@ -159,9 +183,10 @@ export function FloatingStylePreview({
|
|||||||
className="relative overflow-hidden rounded-b-xl"
|
className="relative overflow-hidden rounded-b-xl"
|
||||||
style={{ height: `${previewHeight}px` }}
|
style={{ height: `${previewHeight}px` }}
|
||||||
>
|
>
|
||||||
{(titleFontUrl || subtitleFontUrl) && (
|
{(titleFontUrl || subtitleFontUrl || stFontUrl) && (
|
||||||
<style>{`
|
<style>{`
|
||||||
${titleFontUrl ? `@font-face { font-family: '${titleFontFamilyName}'; src: url('${titleFontUrl}') format('${getFontFormat(activeTitleStyle?.font_file)}'); font-weight: 400; font-style: normal; }` : ''}
|
${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; }` : ''}
|
${subtitleFontUrl ? `@font-face { font-family: '${subtitleFontFamilyName}'; src: url('${subtitleFontUrl}') format('${getFontFormat(activeSubtitleStyle?.font_file)}'); font-weight: 400; font-style: normal; }` : ''}
|
||||||
`}</style>
|
`}</style>
|
||||||
)}
|
)}
|
||||||
@@ -182,24 +207,55 @@ export function FloatingStylePreview({
|
|||||||
top: `${scaledTitleTopMargin}px`,
|
top: `${scaledTitleTopMargin}px`,
|
||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
color: titleColor,
|
display: 'flex',
|
||||||
fontSize: `${scaledTitleFontSize}px`,
|
flexDirection: 'column',
|
||||||
fontWeight: titleFontWeight,
|
alignItems: 'center',
|
||||||
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,
|
|
||||||
padding: '0 5%',
|
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>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ interface GeneratedAudiosPanelProps {
|
|||||||
speed: number;
|
speed: number;
|
||||||
onSpeedChange: (speed: number) => void;
|
onSpeedChange: (speed: number) => void;
|
||||||
ttsMode: string;
|
ttsMode: string;
|
||||||
|
embedded?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function GeneratedAudiosPanel({
|
export function GeneratedAudiosPanel({
|
||||||
@@ -40,6 +41,7 @@ export function GeneratedAudiosPanel({
|
|||||||
speed,
|
speed,
|
||||||
onSpeedChange,
|
onSpeedChange,
|
||||||
ttsMode,
|
ttsMode,
|
||||||
|
embedded = false,
|
||||||
}: GeneratedAudiosPanelProps) {
|
}: GeneratedAudiosPanelProps) {
|
||||||
const [editingId, setEditingId] = useState<string | null>(null);
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
const [editName, setEditName] = useState("");
|
const [editName, setEditName] = useState("");
|
||||||
@@ -123,64 +125,124 @@ export function GeneratedAudiosPanel({
|
|||||||
] as const;
|
] as const;
|
||||||
const currentSpeedLabel = speedOptions.find((o) => o.value === speed)?.label ?? "正常";
|
const currentSpeedLabel = speedOptions.find((o) => o.value === speed)?.label ?? "正常";
|
||||||
|
|
||||||
return (
|
const content = (
|
||||||
<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">
|
{embedded ? (
|
||||||
<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" />
|
{/* Row 1: 语速 + 生成配音 (right-aligned) */}
|
||||||
配音列表
|
<div className="flex justify-end items-center gap-1.5 mb-3">
|
||||||
</h2>
|
{ttsMode === "voiceclone" && (
|
||||||
<div className="flex gap-1.5">
|
<div ref={speedRef} className="relative">
|
||||||
{/* 语速下拉 (仅声音克隆模式) */}
|
<button
|
||||||
{ttsMode === "voiceclone" && (
|
onClick={() => setSpeedOpen((v) => !v)}
|
||||||
<div ref={speedRef} className="relative">
|
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"
|
||||||
<button
|
>
|
||||||
onClick={() => setSpeedOpen((v) => !v)}
|
语速: {currentSpeedLabel}
|
||||||
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"
|
<ChevronDown className={`h-3 w-3 transition-transform ${speedOpen ? "rotate-180" : ""}`} />
|
||||||
>
|
</button>
|
||||||
语速: {currentSpeedLabel}
|
{speedOpen && (
|
||||||
<ChevronDown className={`h-3 w-3 transition-transform ${speedOpen ? "rotate-180" : ""}`} />
|
<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]">
|
||||||
</button>
|
{speedOptions.map((opt) => (
|
||||||
{speedOpen && (
|
<button
|
||||||
<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]">
|
key={opt.value}
|
||||||
{speedOptions.map((opt) => (
|
onClick={() => { onSpeedChange(opt.value); setSpeedOpen(false); }}
|
||||||
<button
|
className={`w-full text-left px-3 py-1.5 text-xs transition-colors ${
|
||||||
key={opt.value}
|
speed === opt.value
|
||||||
onClick={() => { onSpeedChange(opt.value); setSpeedOpen(false); }}
|
? "bg-purple-600/40 text-purple-200"
|
||||||
className={`w-full text-left px-3 py-1.5 text-xs transition-colors ${
|
: "text-gray-300 hover:bg-white/10"
|
||||||
speed === opt.value
|
}`}
|
||||||
? "bg-purple-600/40 text-purple-200"
|
>
|
||||||
: "text-gray-300 hover:bg-white/10"
|
{opt.label}
|
||||||
}`}
|
</button>
|
||||||
>
|
))}
|
||||||
{opt.label}
|
</div>
|
||||||
</button>
|
)}
|
||||||
))}
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
<button
|
||||||
</div>
|
onClick={onGenerateAudio}
|
||||||
)}
|
disabled={isGeneratingAudio || !canGenerate}
|
||||||
<button
|
title={missingRefAudio ? "请先选择参考音频" : !hasText ? "请先输入文案" : ""}
|
||||||
onClick={onGenerateAudio}
|
className={`px-4 py-2 text-sm font-medium rounded-lg transition-all whitespace-nowrap flex items-center gap-1.5 shadow-sm ${
|
||||||
disabled={isGeneratingAudio || !canGenerate}
|
isGeneratingAudio || !canGenerate
|
||||||
title={missingRefAudio ? "请先选择参考音频" : !hasText ? "请先输入文案" : ""}
|
? "bg-gray-600 cursor-not-allowed text-gray-400"
|
||||||
className={`px-2 py-1 text-xs rounded transition-all whitespace-nowrap flex items-center gap-1 ${
|
: "bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-700 hover:to-pink-700 text-white hover:shadow-md"
|
||||||
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-4 w-4" />
|
||||||
}`}
|
生成配音
|
||||||
>
|
</button>
|
||||||
<Mic className="h-3.5 w-3.5" />
|
</div>
|
||||||
生成配音
|
{/* Row 2: 配音列表 + 刷新 */}
|
||||||
</button>
|
<div className="flex justify-between items-center mb-3">
|
||||||
<button
|
<h3 className="text-sm font-medium text-gray-400">配音列表</h3>
|
||||||
onClick={onRefresh}
|
<button
|
||||||
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"
|
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>
|
<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" && (
|
||||||
|
<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-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>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
{/* 缺少参考音频提示 */}
|
{/* 缺少参考音频提示 */}
|
||||||
{missingRefAudio && (
|
{missingRefAudio && (
|
||||||
@@ -250,7 +312,7 @@ export function GeneratedAudiosPanel({
|
|||||||
<div className="text-white text-sm truncate">{audio.name}</div>
|
<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 className="text-gray-400 text-xs">{audio.duration_sec.toFixed(1)}s</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1 pl-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
<div className="flex items-center gap-1 pl-2 opacity-40 group-hover:opacity-100 transition-opacity">
|
||||||
<button
|
<button
|
||||||
onClick={(e) => togglePlay(audio, e)}
|
onClick={(e) => togglePlay(audio, e)}
|
||||||
className="p-1 text-gray-500 hover:text-purple-400 transition-colors"
|
className="p-1 text-gray-500 hover:text-purple-400 transition-colors"
|
||||||
@@ -287,7 +349,14 @@ export function GeneratedAudiosPanel({
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ interface HistoryListProps {
|
|||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
registerVideoRef: (id: string, element: HTMLDivElement | null) => void;
|
registerVideoRef: (id: string, element: HTMLDivElement | null) => void;
|
||||||
formatDate: (timestamp: number) => string;
|
formatDate: (timestamp: number) => string;
|
||||||
|
embedded?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function HistoryList({
|
export function HistoryList({
|
||||||
@@ -26,19 +27,22 @@ export function HistoryList({
|
|||||||
onRefresh,
|
onRefresh,
|
||||||
registerVideoRef,
|
registerVideoRef,
|
||||||
formatDate,
|
formatDate,
|
||||||
|
embedded = false,
|
||||||
}: HistoryListProps) {
|
}: HistoryListProps) {
|
||||||
return (
|
const content = (
|
||||||
<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">
|
{!embedded && (
|
||||||
<h2 className="text-lg font-semibold text-white flex items-center gap-2">📂 历史作品</h2>
|
<div className="flex justify-between items-center mb-4">
|
||||||
<button
|
<h2 className="text-lg font-semibold text-white flex items-center gap-2">历史作品</h2>
|
||||||
onClick={onRefresh}
|
<button
|
||||||
className="px-3 py-1 text-xs bg-white/10 hover:bg-white/20 rounded text-gray-300 flex items-center gap-1"
|
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" />
|
>
|
||||||
刷新
|
<RefreshCw className="h-3.5 w-3.5" />
|
||||||
</button>
|
刷新
|
||||||
</div>
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{generatedVideos.length === 0 ? (
|
{generatedVideos.length === 0 ? (
|
||||||
<div className="text-center py-4 text-gray-500">
|
<div className="text-center py-4 text-gray-500">
|
||||||
<p>暂无生成的作品</p>
|
<p>暂无生成的作品</p>
|
||||||
@@ -66,7 +70,7 @@ export function HistoryList({
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onDeleteVideo(v.id);
|
onDeleteVideo(v.id);
|
||||||
}}
|
}}
|
||||||
className="p-1 text-gray-500 hover:text-red-400 opacity-0 group-hover:opacity-100 transition-opacity"
|
className="p-1 text-gray-500 hover:text-red-400 opacity-40 group-hover:opacity-100 transition-opacity"
|
||||||
title="删除视频"
|
title="删除视频"
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
@@ -75,6 +79,14 @@ export function HistoryList({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (embedded) return content;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white/5 rounded-2xl p-6 border border-white/10 backdrop-blur-sm">
|
||||||
|
{content}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect, useMemo } from "react";
|
import { useEffect, useMemo } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
import { RefreshCw } from "lucide-react";
|
||||||
import VideoPreviewModal from "@/components/VideoPreviewModal";
|
import VideoPreviewModal from "@/components/VideoPreviewModal";
|
||||||
import ScriptExtractionModal from "./ScriptExtractionModal";
|
import ScriptExtractionModal from "./ScriptExtractionModal";
|
||||||
import { useHomeController } from "@/features/home/model/useHomeController";
|
import { useHomeController } from "@/features/home/model/useHomeController";
|
||||||
@@ -70,6 +71,15 @@ export function HomePage() {
|
|||||||
titleFontSize,
|
titleFontSize,
|
||||||
setTitleFontSize,
|
setTitleFontSize,
|
||||||
setTitleSizeLocked,
|
setTitleSizeLocked,
|
||||||
|
videoSecondaryTitle,
|
||||||
|
secondaryTitleInput,
|
||||||
|
selectedSecondaryTitleStyleId,
|
||||||
|
setSelectedSecondaryTitleStyleId,
|
||||||
|
secondaryTitleFontSize,
|
||||||
|
setSecondaryTitleFontSize,
|
||||||
|
setSecondaryTitleSizeLocked,
|
||||||
|
secondaryTitleTopMargin,
|
||||||
|
setSecondaryTitleTopMargin,
|
||||||
subtitleStyles,
|
subtitleStyles,
|
||||||
selectedSubtitleStyleId,
|
selectedSubtitleStyleId,
|
||||||
setSelectedSubtitleStyleId,
|
setSelectedSubtitleStyleId,
|
||||||
@@ -80,6 +90,8 @@ export function HomePage() {
|
|||||||
setTitleTopMargin,
|
setTitleTopMargin,
|
||||||
subtitleBottomMargin,
|
subtitleBottomMargin,
|
||||||
setSubtitleBottomMargin,
|
setSubtitleBottomMargin,
|
||||||
|
titleDisplayMode,
|
||||||
|
setTitleDisplayMode,
|
||||||
outputAspectRatio,
|
outputAspectRatio,
|
||||||
setOutputAspectRatio,
|
setOutputAspectRatio,
|
||||||
resolveAssetUrl,
|
resolveAssetUrl,
|
||||||
@@ -168,7 +180,15 @@ export function HomePage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window === "undefined") return;
|
if (typeof window === "undefined") return;
|
||||||
|
if ("scrollRestoration" in history) {
|
||||||
|
history.scrollRestoration = "manual";
|
||||||
|
}
|
||||||
window.scrollTo({ top: 0, left: 0, behavior: "auto" });
|
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(
|
const clipTrimmerSegment = useMemo(
|
||||||
@@ -190,7 +210,7 @@ export function HomePage() {
|
|||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||||
{/* 左侧: 输入区域 */}
|
{/* 左侧: 输入区域 */}
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* 1. 文案输入 */}
|
{/* 一、文案提取与编辑 */}
|
||||||
<ScriptEditor
|
<ScriptEditor
|
||||||
text={text}
|
text={text}
|
||||||
onChangeText={setText}
|
onChangeText={setText}
|
||||||
@@ -207,7 +227,7 @@ export function HomePage() {
|
|||||||
onDeleteScript={deleteSavedScript}
|
onDeleteScript={deleteSavedScript}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 2. 标题和字幕设置 */}
|
{/* 二、标题与字幕 */}
|
||||||
<TitleSubtitlePanel
|
<TitleSubtitlePanel
|
||||||
showStylePreview={showStylePreview}
|
showStylePreview={showStylePreview}
|
||||||
onTogglePreview={() => setShowStylePreview((prev) => !prev)}
|
onTogglePreview={() => setShowStylePreview((prev) => !prev)}
|
||||||
@@ -215,6 +235,10 @@ export function HomePage() {
|
|||||||
onTitleChange={titleInput.handleChange}
|
onTitleChange={titleInput.handleChange}
|
||||||
onTitleCompositionStart={titleInput.handleCompositionStart}
|
onTitleCompositionStart={titleInput.handleCompositionStart}
|
||||||
onTitleCompositionEnd={titleInput.handleCompositionEnd}
|
onTitleCompositionEnd={titleInput.handleCompositionEnd}
|
||||||
|
videoSecondaryTitle={videoSecondaryTitle}
|
||||||
|
onSecondaryTitleChange={secondaryTitleInput.handleChange}
|
||||||
|
onSecondaryTitleCompositionStart={secondaryTitleInput.handleCompositionStart}
|
||||||
|
onSecondaryTitleCompositionEnd={secondaryTitleInput.handleCompositionEnd}
|
||||||
titleStyles={titleStyles}
|
titleStyles={titleStyles}
|
||||||
selectedTitleStyleId={selectedTitleStyleId}
|
selectedTitleStyleId={selectedTitleStyleId}
|
||||||
onSelectTitleStyle={setSelectedTitleStyleId}
|
onSelectTitleStyle={setSelectedTitleStyleId}
|
||||||
@@ -223,6 +247,15 @@ export function HomePage() {
|
|||||||
setTitleFontSize(value);
|
setTitleFontSize(value);
|
||||||
setTitleSizeLocked(true);
|
setTitleSizeLocked(true);
|
||||||
}}
|
}}
|
||||||
|
selectedSecondaryTitleStyleId={selectedSecondaryTitleStyleId}
|
||||||
|
onSelectSecondaryTitleStyle={setSelectedSecondaryTitleStyleId}
|
||||||
|
secondaryTitleFontSize={secondaryTitleFontSize}
|
||||||
|
onSecondaryTitleFontSizeChange={(value) => {
|
||||||
|
setSecondaryTitleFontSize(value);
|
||||||
|
setSecondaryTitleSizeLocked(true);
|
||||||
|
}}
|
||||||
|
secondaryTitleTopMargin={secondaryTitleTopMargin}
|
||||||
|
onSecondaryTitleTopMarginChange={setSecondaryTitleTopMargin}
|
||||||
subtitleStyles={subtitleStyles}
|
subtitleStyles={subtitleStyles}
|
||||||
selectedSubtitleStyleId={selectedSubtitleStyleId}
|
selectedSubtitleStyleId={selectedSubtitleStyleId}
|
||||||
onSelectSubtitleStyle={setSelectedSubtitleStyleId}
|
onSelectSubtitleStyle={setSelectedSubtitleStyleId}
|
||||||
@@ -235,6 +268,8 @@ export function HomePage() {
|
|||||||
onTitleTopMarginChange={setTitleTopMargin}
|
onTitleTopMarginChange={setTitleTopMargin}
|
||||||
subtitleBottomMargin={subtitleBottomMargin}
|
subtitleBottomMargin={subtitleBottomMargin}
|
||||||
onSubtitleBottomMarginChange={setSubtitleBottomMargin}
|
onSubtitleBottomMarginChange={setSubtitleBottomMargin}
|
||||||
|
titleDisplayMode={titleDisplayMode}
|
||||||
|
onTitleDisplayModeChange={setTitleDisplayMode}
|
||||||
resolveAssetUrl={resolveAssetUrl}
|
resolveAssetUrl={resolveAssetUrl}
|
||||||
getFontFormat={getFontFormat}
|
getFontFormat={getFontFormat}
|
||||||
buildTextShadow={buildTextShadow}
|
buildTextShadow={buildTextShadow}
|
||||||
@@ -242,65 +277,77 @@ export function HomePage() {
|
|||||||
previewBaseHeight={outputAspectRatio === "16:9" ? 1080 : 1920}
|
previewBaseHeight={outputAspectRatio === "16:9" ? 1080 : 1920}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 3. 配音方式选择 */}
|
{/* 三、配音 */}
|
||||||
<VoiceSelector
|
<div className="bg-white/5 rounded-2xl p-4 sm:p-6 border border-white/10 backdrop-blur-sm">
|
||||||
ttsMode={ttsMode}
|
<h2 className="text-base sm:text-lg font-semibold text-white mb-4">
|
||||||
onSelectTtsMode={setTtsMode}
|
三、配音
|
||||||
voices={voices}
|
</h2>
|
||||||
voice={voice}
|
<h3 className="text-sm font-medium text-gray-400 mb-3">配音方式</h3>
|
||||||
onSelectVoice={setVoice}
|
<VoiceSelector
|
||||||
voiceCloneSlot={(
|
embedded
|
||||||
<RefAudioPanel
|
ttsMode={ttsMode}
|
||||||
refAudios={refAudios}
|
onSelectTtsMode={setTtsMode}
|
||||||
selectedRefAudio={selectedRefAudio}
|
voices={voices}
|
||||||
onSelectRefAudio={handleSelectRefAudio}
|
voice={voice}
|
||||||
isUploadingRef={isUploadingRef}
|
onSelectVoice={setVoice}
|
||||||
uploadRefError={uploadRefError}
|
voiceCloneSlot={(
|
||||||
onClearUploadRefError={() => setUploadRefError(null)}
|
<RefAudioPanel
|
||||||
onUploadRefAudio={uploadRefAudio}
|
refAudios={refAudios}
|
||||||
onFetchRefAudios={fetchRefAudios}
|
selectedRefAudio={selectedRefAudio}
|
||||||
playingAudioId={playingAudioId}
|
onSelectRefAudio={handleSelectRefAudio}
|
||||||
onTogglePlayPreview={togglePlayPreview}
|
isUploadingRef={isUploadingRef}
|
||||||
editingAudioId={editingAudioId}
|
uploadRefError={uploadRefError}
|
||||||
editName={editName}
|
onClearUploadRefError={() => setUploadRefError(null)}
|
||||||
onEditNameChange={setEditName}
|
onUploadRefAudio={uploadRefAudio}
|
||||||
onStartEditing={startEditing}
|
onFetchRefAudios={fetchRefAudios}
|
||||||
onSaveEditing={saveEditing}
|
playingAudioId={playingAudioId}
|
||||||
onCancelEditing={cancelEditing}
|
onTogglePlayPreview={togglePlayPreview}
|
||||||
onDeleteRefAudio={deleteRefAudio}
|
editingAudioId={editingAudioId}
|
||||||
onRetranscribe={retranscribeRefAudio}
|
editName={editName}
|
||||||
retranscribingId={retranscribingId}
|
onEditNameChange={setEditName}
|
||||||
recordedBlob={recordedBlob}
|
onStartEditing={startEditing}
|
||||||
isRecording={isRecording}
|
onSaveEditing={saveEditing}
|
||||||
recordingTime={recordingTime}
|
onCancelEditing={cancelEditing}
|
||||||
onStartRecording={startRecording}
|
onDeleteRefAudio={deleteRefAudio}
|
||||||
onStopRecording={stopRecording}
|
onRetranscribe={retranscribeRefAudio}
|
||||||
onUseRecording={useRecording}
|
retranscribingId={retranscribingId}
|
||||||
formatRecordingTime={formatRecordingTime}
|
recordedBlob={recordedBlob}
|
||||||
/>
|
isRecording={isRecording}
|
||||||
)}
|
recordingTime={recordingTime}
|
||||||
/>
|
onStartRecording={startRecording}
|
||||||
|
onStopRecording={stopRecording}
|
||||||
|
onUseRecording={useRecording}
|
||||||
|
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}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 4. 配音列表 */}
|
{/* 四、素材编辑 */}
|
||||||
<GeneratedAudiosPanel
|
<div className="bg-white/5 rounded-2xl p-4 sm:p-6 border border-white/10 backdrop-blur-sm">
|
||||||
generatedAudios={generatedAudios}
|
<h2 className="text-base sm:text-lg font-semibold text-white mb-4">
|
||||||
selectedAudioId={selectedAudioId}
|
四、素材编辑
|
||||||
isGeneratingAudio={isGeneratingAudio}
|
</h2>
|
||||||
audioTask={audioTask}
|
<MaterialSelector
|
||||||
onGenerateAudio={handleGenerateAudio}
|
embedded
|
||||||
onRefresh={() => fetchGeneratedAudios()}
|
|
||||||
onSelectAudio={selectAudio}
|
|
||||||
onDeleteAudio={deleteAudio}
|
|
||||||
onRenameAudio={renameAudio}
|
|
||||||
hasText={!!text.trim()}
|
|
||||||
missingRefAudio={ttsMode === "voiceclone" && !selectedRefAudio}
|
|
||||||
speed={speed}
|
|
||||||
onSpeedChange={setSpeed}
|
|
||||||
ttsMode={ttsMode}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 5. 视频素材 */}
|
|
||||||
<MaterialSelector
|
|
||||||
materials={materials}
|
materials={materials}
|
||||||
selectedMaterials={selectedMaterials}
|
selectedMaterials={selectedMaterials}
|
||||||
isFetching={isFetching}
|
isFetching={isFetching}
|
||||||
@@ -324,32 +371,33 @@ export function HomePage() {
|
|||||||
onClearUploadError={() => setUploadError(null)}
|
onClearUploadError={() => setUploadError(null)}
|
||||||
registerMaterialRef={registerMaterialRef}
|
registerMaterialRef={registerMaterialRef}
|
||||||
/>
|
/>
|
||||||
|
<div className="border-t border-white/10 my-4" />
|
||||||
{/* 5.5 时间轴编辑器 — 未选配音/素材时模糊遮挡 */}
|
<div className="relative">
|
||||||
<div className="relative">
|
{(!selectedAudio || selectedMaterials.length === 0) && (
|
||||||
{(!selectedAudio || selectedMaterials.length === 0) && (
|
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm rounded-xl flex items-center justify-center z-10">
|
||||||
<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">
|
||||||
<p className="text-gray-400">
|
{!selectedAudio ? "请先生成并选中配音" : "请先选择素材"}
|
||||||
{!selectedAudio ? "请先生成并选中配音" : "请先选择素材"}
|
</p>
|
||||||
</p>
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
<TimelineEditor
|
||||||
<TimelineEditor
|
embedded
|
||||||
audioDuration={selectedAudio?.duration_sec ?? 0}
|
audioDuration={selectedAudio?.duration_sec ?? 0}
|
||||||
audioUrl={selectedAudio ? (resolveMediaUrl(selectedAudio.path) || "") : ""}
|
audioUrl={selectedAudio ? (resolveMediaUrl(selectedAudio.path) || "") : ""}
|
||||||
segments={timelineSegments}
|
segments={timelineSegments}
|
||||||
materials={materials}
|
materials={materials}
|
||||||
outputAspectRatio={outputAspectRatio}
|
outputAspectRatio={outputAspectRatio}
|
||||||
onOutputAspectRatioChange={setOutputAspectRatio}
|
onOutputAspectRatioChange={setOutputAspectRatio}
|
||||||
onReorderSegment={reorderSegments}
|
onReorderSegment={reorderSegments}
|
||||||
onClickSegment={(seg) => {
|
onClickSegment={(seg) => {
|
||||||
setClipTrimmerSegmentId(seg.id);
|
setClipTrimmerSegmentId(seg.id);
|
||||||
setClipTrimmerOpen(true);
|
setClipTrimmerOpen(true);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 6. 背景音乐 */}
|
{/* 背景音乐 (不编号) */}
|
||||||
<BgmPanel
|
<BgmPanel
|
||||||
bgmList={bgmList}
|
bgmList={bgmList}
|
||||||
bgmLoading={bgmLoading}
|
bgmLoading={bgmLoading}
|
||||||
@@ -367,7 +415,7 @@ export function HomePage() {
|
|||||||
registerBgmItemRef={registerBgmItemRef}
|
registerBgmItemRef={registerBgmItemRef}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 7. 生成按钮 */}
|
{/* 生成按钮 (不编号) */}
|
||||||
<GenerateActionBar
|
<GenerateActionBar
|
||||||
isGenerating={isGenerating}
|
isGenerating={isGenerating}
|
||||||
progress={currentTask?.progress || 0}
|
progress={currentTask?.progress || 0}
|
||||||
@@ -377,23 +425,59 @@ export function HomePage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 右侧: 预览区域 */}
|
{/* 右侧: 作品区域 */}
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<PreviewPanel
|
{/* 生成进度(在作品卡片上方) */}
|
||||||
currentTask={currentTask}
|
{currentTask && isGenerating && (
|
||||||
isGenerating={isGenerating}
|
<div className="bg-white/5 rounded-2xl p-4 sm:p-6 border border-purple-500/30 backdrop-blur-sm">
|
||||||
generatedVideo={generatedVideo}
|
<div className="space-y-3">
|
||||||
/>
|
<div className="flex justify-between text-sm text-purple-300 mb-1">
|
||||||
|
<span>正在AI生成中...</span>
|
||||||
<HistoryList
|
<span>{currentTask.progress || 0}%</span>
|
||||||
generatedVideos={generatedVideos}
|
</div>
|
||||||
selectedVideoId={selectedVideoId}
|
<div className="h-3 bg-black/30 rounded-full overflow-hidden">
|
||||||
onSelectVideo={handleSelectVideo}
|
<div
|
||||||
onDeleteVideo={deleteVideo}
|
className="h-full bg-gradient-to-r from-purple-500 to-pink-500 transition-all duration-300"
|
||||||
onRefresh={() => fetchGeneratedVideos()}
|
style={{ width: `${currentTask.progress || 0}%` }}
|
||||||
registerVideoRef={registerVideoRef}
|
/>
|
||||||
formatDate={formatDate}
|
</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}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { type ChangeEvent, type MouseEvent } from "react";
|
import { type ChangeEvent, type MouseEvent, useMemo } from "react";
|
||||||
import { Upload, RefreshCw, Eye, Trash2, X, Pencil, Check } from "lucide-react";
|
import { Upload, RefreshCw, Eye, Trash2, X, Pencil, Check } from "lucide-react";
|
||||||
import type { Material } from "@/shared/types/material";
|
import type { Material } from "@/shared/types/material";
|
||||||
|
|
||||||
@@ -25,6 +25,7 @@ interface MaterialSelectorProps {
|
|||||||
onDeleteMaterial: (id: string) => void;
|
onDeleteMaterial: (id: string) => void;
|
||||||
onClearUploadError: () => void;
|
onClearUploadError: () => void;
|
||||||
registerMaterialRef: (id: string, element: HTMLDivElement | null) => void;
|
registerMaterialRef: (id: string, element: HTMLDivElement | null) => void;
|
||||||
|
embedded?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MaterialSelector({
|
export function MaterialSelector({
|
||||||
@@ -50,19 +51,27 @@ export function MaterialSelector({
|
|||||||
onDeleteMaterial,
|
onDeleteMaterial,
|
||||||
onClearUploadError,
|
onClearUploadError,
|
||||||
registerMaterialRef,
|
registerMaterialRef,
|
||||||
|
embedded = false,
|
||||||
}: MaterialSelectorProps) {
|
}: MaterialSelectorProps) {
|
||||||
const selectedSet = new Set(selectedMaterials);
|
const selectedSet = useMemo(() => new Set(selectedMaterials), [selectedMaterials]);
|
||||||
const isFull = selectedMaterials.length >= 4;
|
const isFull = selectedMaterials.length >= 4;
|
||||||
|
|
||||||
return (
|
const content = (
|
||||||
<div className="bg-white/5 rounded-2xl p-4 sm:p-6 border border-white/10 backdrop-blur-sm">
|
<>
|
||||||
<div className="flex justify-between items-center gap-2 mb-4">
|
<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">
|
{!embedded ? (
|
||||||
📹 视频素材
|
<h2 className="text-base sm:text-lg font-semibold text-white flex items-center gap-2 min-w-0">
|
||||||
<span className="ml-1 text-[11px] sm:text-xs text-gray-400/90 font-normal">
|
<span className="shrink-0">视频素材</span>
|
||||||
(可多选,最多4个)
|
<span className="text-[11px] sm:text-xs text-gray-400/90 font-normal truncate">
|
||||||
</span>
|
(上传自拍视频,最多可选4个)
|
||||||
</h2>
|
</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">
|
<div className="flex gap-1.5">
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
@@ -94,7 +103,7 @@ export function MaterialSelector({
|
|||||||
{isUploading && (
|
{isUploading && (
|
||||||
<div className="mb-4 p-4 bg-purple-500/10 rounded-xl border border-purple-500/30">
|
<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">
|
<div className="flex justify-between text-sm text-purple-300 mb-2">
|
||||||
<span>📤 上传中...</span>
|
<span>上传中...</span>
|
||||||
<span>{uploadProgress}%</span>
|
<span>{uploadProgress}%</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-2 bg-black/30 rounded-full overflow-hidden">
|
<div className="h-2 bg-black/30 rounded-full overflow-hidden">
|
||||||
@@ -108,7 +117,7 @@ export function MaterialSelector({
|
|||||||
|
|
||||||
{uploadError && (
|
{uploadError && (
|
||||||
<div className="mb-4 p-4 bg-red-500/20 text-red-200 rounded-xl text-sm flex justify-between items-center">
|
<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">
|
<button onClick={onClearUploadError} className="text-red-300 hover:text-white">
|
||||||
<X className="h-3.5 w-3.5" />
|
<X className="h-3.5 w-3.5" />
|
||||||
</button>
|
</button>
|
||||||
@@ -138,7 +147,7 @@ export function MaterialSelector({
|
|||||||
<div className="text-5xl mb-4">📁</div>
|
<div className="text-5xl mb-4">📁</div>
|
||||||
<p>暂无视频素材</p>
|
<p>暂无视频素材</p>
|
||||||
<p className="text-sm mt-2">
|
<p className="text-sm mt-2">
|
||||||
点击上方「📤 上传视频」按钮添加视频素材
|
点击上方「上传」按钮添加视频素材
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -183,7 +192,7 @@ export function MaterialSelector({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<button onClick={() => onToggleMaterial(m.id)} className="flex-1 text-left flex items-center gap-2">
|
<button onClick={() => onToggleMaterial(m.id)} disabled={isFull && !isSelected} className="flex-1 text-left flex items-center gap-2">
|
||||||
{/* 复选框 */}
|
{/* 复选框 */}
|
||||||
<span
|
<span
|
||||||
className={`flex-shrink-0 w-4 h-4 rounded border flex items-center justify-center text-[10px] ${isSelected
|
className={`flex-shrink-0 w-4 h-4 rounded border flex items-center justify-center text-[10px] ${isSelected
|
||||||
@@ -207,7 +216,7 @@ export function MaterialSelector({
|
|||||||
onPreviewMaterial(m.path);
|
onPreviewMaterial(m.path);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="p-1 text-gray-500 hover:text-white opacity-0 group-hover:opacity-100 transition-opacity"
|
className="p-1 text-gray-500 hover:text-white opacity-40 group-hover:opacity-100 transition-opacity"
|
||||||
title="预览视频"
|
title="预览视频"
|
||||||
>
|
>
|
||||||
<Eye className="h-4 w-4" />
|
<Eye className="h-4 w-4" />
|
||||||
@@ -215,7 +224,7 @@ export function MaterialSelector({
|
|||||||
{editingMaterialId !== m.id && (
|
{editingMaterialId !== m.id && (
|
||||||
<button
|
<button
|
||||||
onClick={(e) => onStartEditing(m, e)}
|
onClick={(e) => onStartEditing(m, e)}
|
||||||
className="p-1 text-gray-500 hover:text-white opacity-0 group-hover:opacity-100 transition-opacity"
|
className="p-1 text-gray-500 hover:text-white opacity-40 group-hover:opacity-100 transition-opacity"
|
||||||
title="重命名"
|
title="重命名"
|
||||||
>
|
>
|
||||||
<Pencil className="h-4 w-4" />
|
<Pencil className="h-4 w-4" />
|
||||||
@@ -226,7 +235,7 @@ export function MaterialSelector({
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onDeleteMaterial(m.id);
|
onDeleteMaterial(m.id);
|
||||||
}}
|
}}
|
||||||
className="p-1 text-gray-500 hover:text-red-400 opacity-0 group-hover:opacity-100 transition-opacity"
|
className="p-1 text-gray-500 hover:text-red-400 opacity-40 group-hover:opacity-100 transition-opacity"
|
||||||
title="删除素材"
|
title="删除素材"
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
@@ -237,6 +246,14 @@ export function MaterialSelector({
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,18 +12,20 @@ interface PreviewPanelProps {
|
|||||||
currentTask: Task | null;
|
currentTask: Task | null;
|
||||||
isGenerating: boolean;
|
isGenerating: boolean;
|
||||||
generatedVideo: string | null;
|
generatedVideo: string | null;
|
||||||
|
embedded?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PreviewPanel({
|
export function PreviewPanel({
|
||||||
currentTask,
|
currentTask,
|
||||||
isGenerating,
|
isGenerating,
|
||||||
generatedVideo,
|
generatedVideo,
|
||||||
|
embedded = false,
|
||||||
}: PreviewPanelProps) {
|
}: PreviewPanelProps) {
|
||||||
return (
|
const content = (
|
||||||
<>
|
<>
|
||||||
{currentTask && isGenerating && (
|
{currentTask && isGenerating && (
|
||||||
<div className="bg-white/5 rounded-2xl p-6 border border-white/10 backdrop-blur-sm">
|
<div className={embedded ? "mb-4" : "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>
|
{!embedded && <h2 className="text-lg font-semibold text-white mb-4">生成进度</h2>}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="h-3 bg-black/30 rounded-full overflow-hidden">
|
<div className="h-3 bg-black/30 rounded-full overflow-hidden">
|
||||||
<div
|
<div
|
||||||
@@ -36,8 +38,8 @@ export function PreviewPanel({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="bg-white/5 rounded-2xl p-6 border border-white/10 backdrop-blur-sm">
|
<div className={embedded ? "" : "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>
|
{!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">
|
<div className="aspect-video bg-black/50 rounded-xl overflow-hidden flex items-center justify-center">
|
||||||
{generatedVideo ? (
|
{generatedVideo ? (
|
||||||
<video src={generatedVideo} controls preload="metadata" className="w-full h-full object-contain" />
|
<video src={generatedVideo} controls preload="metadata" className="w-full h-full object-contain" />
|
||||||
@@ -71,4 +73,6 @@ export function PreviewPanel({
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
return content;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ export function RefAudioPanel({
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<div className="flex justify-between items-center mb-2">
|
<div className="flex justify-between items-center mb-2">
|
||||||
<span className="text-sm text-gray-300">📁 我的参考音频</span>
|
<span className="text-sm text-gray-300">📁 我的参考音频 <span className="text-xs text-gray-500 font-normal">(上传3-10秒语音样本)</span></span>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
@@ -187,7 +187,7 @@ export function RefAudioPanel({
|
|||||||
<div className="text-white text-xs truncate pr-1 flex-1" title={audio.name}>
|
<div className="text-white text-xs truncate pr-1 flex-1" title={audio.name}>
|
||||||
{audio.name}
|
{audio.name}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
<div className="flex gap-1 opacity-40 group-hover:opacity-100 transition-opacity">
|
||||||
<button
|
<button
|
||||||
onClick={(e) => onTogglePlayPreview(audio, e)}
|
onClick={(e) => onTogglePlayPreview(audio, e)}
|
||||||
className="text-gray-400 hover:text-purple-400 text-xs"
|
className="text-gray-400 hover:text-purple-400 text-xs"
|
||||||
@@ -287,9 +287,6 @@ export function RefAudioPanel({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-xs text-gray-500 mt-2 border-t border-white/10 pt-3">
|
|
||||||
上传任意语音样本(3-10秒),系统将自动识别内容并克隆声音
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ 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="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">
|
<div className="mb-4 space-y-3">
|
||||||
<h2 className="text-base sm:text-lg font-semibold text-white flex items-center gap-2">
|
<h2 className="text-base sm:text-lg font-semibold text-white flex items-center gap-2">
|
||||||
✍️ 文案提取与编辑
|
一、文案提取与编辑
|
||||||
</h2>
|
</h2>
|
||||||
<div className="flex gap-2 flex-wrap justify-end items-center">
|
<div className="flex gap-2 flex-wrap justify-end items-center">
|
||||||
{/* 历史文案 */}
|
{/* 历史文案 */}
|
||||||
@@ -123,7 +123,7 @@ export function ScriptEditor({
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onDeleteScript(script.id);
|
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" />
|
<Trash2 className="h-3 w-3" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -26,9 +26,13 @@ export default function ScriptExtractionModal({
|
|||||||
selectedFile,
|
selectedFile,
|
||||||
activeTab,
|
activeTab,
|
||||||
inputUrl,
|
inputUrl,
|
||||||
|
customPrompt,
|
||||||
|
showCustomPrompt,
|
||||||
setDoRewrite,
|
setDoRewrite,
|
||||||
setActiveTab,
|
setActiveTab,
|
||||||
setInputUrl,
|
setInputUrl,
|
||||||
|
setCustomPrompt,
|
||||||
|
setShowCustomPrompt,
|
||||||
handleDrag,
|
handleDrag,
|
||||||
handleDrop,
|
handleDrop,
|
||||||
handleFileChange,
|
handleFileChange,
|
||||||
@@ -187,18 +191,43 @@ export default function ScriptExtractionModal({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Options */}
|
{/* Options */}
|
||||||
<div className="flex items-center gap-3 bg-white/5 rounded-xl p-4 border border-white/10">
|
<div className="bg-white/5 rounded-xl border border-white/10 overflow-hidden">
|
||||||
<label className="flex items-center gap-2 cursor-pointer">
|
<div className="flex items-center justify-between p-4">
|
||||||
<input
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
type="checkbox"
|
<input
|
||||||
checked={doRewrite}
|
type="checkbox"
|
||||||
onChange={(e) => setDoRewrite(e.target.checked)}
|
checked={doRewrite}
|
||||||
className="w-4 h-4 rounded bg-white/10 border-white/20 text-purple-500 focus:ring-purple-500"
|
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 className="text-sm text-gray-300">
|
||||||
</span>
|
AI 智能改写
|
||||||
</label>
|
</span>
|
||||||
|
</label>
|
||||||
|
{doRewrite && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowCustomPrompt(!showCustomPrompt)}
|
||||||
|
className="text-xs text-purple-400 hover:text-purple-300 transition-colors flex items-center gap-1"
|
||||||
|
>
|
||||||
|
自定义提示词 {showCustomPrompt ? "▲" : "▼"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{doRewrite && showCustomPrompt && (
|
||||||
|
<div className="px-4 pb-4 space-y-2">
|
||||||
|
<textarea
|
||||||
|
value={customPrompt}
|
||||||
|
onChange={(e) => setCustomPrompt(e.target.value)}
|
||||||
|
placeholder="输入自定义改写提示词..."
|
||||||
|
rows={3}
|
||||||
|
className="w-full bg-black/20 border border-white/10 rounded-lg 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>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Error */}
|
{/* Error */}
|
||||||
@@ -261,7 +290,7 @@ export default function ScriptExtractionModal({
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<h4 className="font-semibold text-purple-300 flex items-center gap-2">
|
<h4 className="font-semibold text-purple-300 flex items-center gap-2">
|
||||||
✨ AI 洗稿结果{" "}
|
✨ AI 改写结果{" "}
|
||||||
<span className="text-xs font-normal text-purple-400/70">
|
<span className="text-xs font-normal text-purple-400/70">
|
||||||
(推荐)
|
(推荐)
|
||||||
</span>
|
</span>
|
||||||
@@ -281,7 +310,7 @@ export default function ScriptExtractionModal({
|
|||||||
📋 复制内容
|
📋 复制内容
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-purple-900/10 border border-purple-500/20 rounded-xl p-4 max-h-60 overflow-y-auto custom-scrollbar">
|
<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">
|
<p className="text-gray-200 text-sm leading-relaxed whitespace-pre-wrap">
|
||||||
{rewrittenScript}
|
{rewrittenScript}
|
||||||
</p>
|
</p>
|
||||||
@@ -309,7 +338,7 @@ export default function ScriptExtractionModal({
|
|||||||
复制
|
复制
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-white/5 border border-white/10 rounded-xl p-4 max-h-40 overflow-y-auto custom-scrollbar">
|
<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">
|
<p className="text-gray-400 text-sm leading-relaxed whitespace-pre-wrap">
|
||||||
{script}
|
{script}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { useEffect, useRef, useCallback, useState } from "react";
|
import { useEffect, useRef, useCallback, useState, useMemo } from "react";
|
||||||
import WaveSurfer from "wavesurfer.js";
|
import WaveSurfer from "wavesurfer.js";
|
||||||
import { ChevronDown } from "lucide-react";
|
import { ChevronDown, GripVertical } from "lucide-react";
|
||||||
import type { TimelineSegment } from "@/features/home/model/useTimelineEditor";
|
import type { TimelineSegment } from "@/features/home/model/useTimelineEditor";
|
||||||
import type { Material } from "@/shared/types/material";
|
import type { Material } from "@/shared/types/material";
|
||||||
|
|
||||||
interface TimelineEditorProps {
|
interface TimelineEditorProps {
|
||||||
audioDuration: number;
|
audioDuration: number;
|
||||||
audioUrl: string;
|
audioUrl: string;
|
||||||
@@ -13,14 +13,15 @@ interface TimelineEditorProps {
|
|||||||
onOutputAspectRatioChange: (ratio: "9:16" | "16:9") => void;
|
onOutputAspectRatioChange: (ratio: "9:16" | "16:9") => void;
|
||||||
onReorderSegment: (fromIdx: number, toIdx: number) => void;
|
onReorderSegment: (fromIdx: number, toIdx: number) => void;
|
||||||
onClickSegment: (segment: TimelineSegment) => void;
|
onClickSegment: (segment: TimelineSegment) => void;
|
||||||
|
embedded?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatTime(sec: number): string {
|
function formatTime(sec: number): string {
|
||||||
const m = Math.floor(sec / 60);
|
const m = Math.floor(sec / 60);
|
||||||
const s = sec % 60;
|
const s = sec % 60;
|
||||||
return `${String(m).padStart(2, "0")}:${s.toFixed(1).padStart(4, "0")}`;
|
return `${String(m).padStart(2, "0")}:${s.toFixed(1).padStart(4, "0")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TimelineEditor({
|
export function TimelineEditor({
|
||||||
audioDuration,
|
audioDuration,
|
||||||
audioUrl,
|
audioUrl,
|
||||||
@@ -30,12 +31,13 @@ export function TimelineEditor({
|
|||||||
onOutputAspectRatioChange,
|
onOutputAspectRatioChange,
|
||||||
onReorderSegment,
|
onReorderSegment,
|
||||||
onClickSegment,
|
onClickSegment,
|
||||||
|
embedded = false,
|
||||||
}: TimelineEditorProps) {
|
}: TimelineEditorProps) {
|
||||||
const waveRef = useRef<HTMLDivElement>(null);
|
const waveRef = useRef<HTMLDivElement>(null);
|
||||||
const wsRef = useRef<WaveSurfer | null>(null);
|
const wsRef = useRef<WaveSurfer | null>(null);
|
||||||
const [waveReady, setWaveReady] = useState(false);
|
const [waveReady, setWaveReady] = useState(false);
|
||||||
const [isPlaying, setIsPlaying] = useState(false);
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
|
|
||||||
// Refs for high-frequency DOM updates (avoid 60fps re-renders)
|
// Refs for high-frequency DOM updates (avoid 60fps re-renders)
|
||||||
const playheadRef = useRef<HTMLDivElement>(null);
|
const playheadRef = useRef<HTMLDivElement>(null);
|
||||||
const timeRef = useRef<HTMLSpanElement>(null);
|
const timeRef = useRef<HTMLSpanElement>(null);
|
||||||
@@ -44,7 +46,7 @@ export function TimelineEditor({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
audioDurationRef.current = audioDuration;
|
audioDurationRef.current = audioDuration;
|
||||||
}, [audioDuration]);
|
}, [audioDuration]);
|
||||||
|
|
||||||
// Drag-to-reorder state
|
// Drag-to-reorder state
|
||||||
const [dragFromIdx, setDragFromIdx] = useState<number | null>(null);
|
const [dragFromIdx, setDragFromIdx] = useState<number | null>(null);
|
||||||
const [dragOverIdx, setDragOverIdx] = useState<number | null>(null);
|
const [dragOverIdx, setDragOverIdx] = useState<number | null>(null);
|
||||||
@@ -68,57 +70,57 @@ export function TimelineEditor({
|
|||||||
if (ratioOpen) document.addEventListener("mousedown", handler);
|
if (ratioOpen) document.addEventListener("mousedown", handler);
|
||||||
return () => document.removeEventListener("mousedown", handler);
|
return () => document.removeEventListener("mousedown", handler);
|
||||||
}, [ratioOpen]);
|
}, [ratioOpen]);
|
||||||
|
|
||||||
// Create / recreate wavesurfer when audioUrl changes
|
// Create / recreate wavesurfer when audioUrl changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!waveRef.current || !audioUrl) return;
|
if (!waveRef.current || !audioUrl) return;
|
||||||
|
|
||||||
const playheadEl = playheadRef.current;
|
const playheadEl = playheadRef.current;
|
||||||
const timeEl = timeRef.current;
|
const timeEl = timeRef.current;
|
||||||
|
|
||||||
// Destroy previous instance
|
// Destroy previous instance
|
||||||
if (wsRef.current) {
|
if (wsRef.current) {
|
||||||
wsRef.current.destroy();
|
wsRef.current.destroy();
|
||||||
wsRef.current = null;
|
wsRef.current = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ws = WaveSurfer.create({
|
const ws = WaveSurfer.create({
|
||||||
container: waveRef.current,
|
container: waveRef.current,
|
||||||
height: 56,
|
height: 56,
|
||||||
waveColor: "#6d28d9",
|
waveColor: "#6d28d9",
|
||||||
progressColor: "#a855f7",
|
progressColor: "#a855f7",
|
||||||
barWidth: 2,
|
barWidth: 2,
|
||||||
barGap: 1,
|
barGap: 1,
|
||||||
barRadius: 2,
|
barRadius: 2,
|
||||||
cursorWidth: 1,
|
cursorWidth: 1,
|
||||||
cursorColor: "#e879f9",
|
cursorColor: "#e879f9",
|
||||||
interact: true,
|
interact: true,
|
||||||
normalize: true,
|
normalize: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Click waveform → seek + auto-play
|
// Click waveform → seek + auto-play
|
||||||
ws.on("interaction", () => ws.play());
|
ws.on("interaction", () => ws.play());
|
||||||
ws.on("play", () => setIsPlaying(true));
|
ws.on("play", () => setIsPlaying(true));
|
||||||
ws.on("pause", () => setIsPlaying(false));
|
ws.on("pause", () => setIsPlaying(false));
|
||||||
ws.on("finish", () => {
|
ws.on("finish", () => {
|
||||||
setIsPlaying(false);
|
setIsPlaying(false);
|
||||||
if (playheadRef.current) playheadRef.current.style.display = "none";
|
if (playheadRef.current) playheadRef.current.style.display = "none";
|
||||||
});
|
});
|
||||||
// High-frequency: update playhead + time via refs (no React re-render)
|
// High-frequency: update playhead + time via refs (no React re-render)
|
||||||
ws.on("timeupdate", (time: number) => {
|
ws.on("timeupdate", (time: number) => {
|
||||||
const dur = audioDurationRef.current;
|
const dur = audioDurationRef.current;
|
||||||
if (playheadRef.current && dur > 0) {
|
if (playheadRef.current && dur > 0) {
|
||||||
playheadRef.current.style.left = `${(time / dur) * 100}%`;
|
playheadRef.current.style.left = `${(time / dur) * 100}%`;
|
||||||
playheadRef.current.style.display = "block";
|
playheadRef.current.style.display = "block";
|
||||||
}
|
}
|
||||||
if (timeRef.current) {
|
if (timeRef.current) {
|
||||||
timeRef.current.textContent = formatTime(time);
|
timeRef.current.textContent = formatTime(time);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
ws.load(audioUrl);
|
ws.load(audioUrl);
|
||||||
wsRef.current = ws;
|
wsRef.current = ws;
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
ws.destroy();
|
ws.destroy();
|
||||||
wsRef.current = null;
|
wsRef.current = null;
|
||||||
@@ -127,60 +129,64 @@ export function TimelineEditor({
|
|||||||
if (timeEl) timeEl.textContent = formatTime(0);
|
if (timeEl) timeEl.textContent = formatTime(0);
|
||||||
};
|
};
|
||||||
}, [audioUrl, waveReady]);
|
}, [audioUrl, waveReady]);
|
||||||
|
|
||||||
// Callback ref to detect when waveRef div mounts
|
// Callback ref to detect when waveRef div mounts
|
||||||
const waveCallbackRef = useCallback((node: HTMLDivElement | null) => {
|
const waveCallbackRef = useCallback((node: HTMLDivElement | null) => {
|
||||||
(waveRef as React.MutableRefObject<HTMLDivElement | null>).current = node;
|
(waveRef as React.MutableRefObject<HTMLDivElement | null>).current = node;
|
||||||
setWaveReady(!!node);
|
setWaveReady(!!node);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handlePlayPause = useCallback(() => {
|
const handlePlayPause = useCallback(() => {
|
||||||
wsRef.current?.playPause();
|
wsRef.current?.playPause();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Drag-to-reorder handlers
|
// Drag-to-reorder handlers
|
||||||
const handleDragStart = useCallback((idx: number, e: React.DragEvent) => {
|
const handleDragStart = useCallback((idx: number, e: React.DragEvent) => {
|
||||||
setDragFromIdx(idx);
|
setDragFromIdx(idx);
|
||||||
e.dataTransfer.effectAllowed = "move";
|
e.dataTransfer.effectAllowed = "move";
|
||||||
e.dataTransfer.setData("text/plain", String(idx));
|
e.dataTransfer.setData("text/plain", String(idx));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleDragOver = useCallback((idx: number, e: React.DragEvent) => {
|
const handleDragOver = useCallback((idx: number, e: React.DragEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.dataTransfer.dropEffect = "move";
|
e.dataTransfer.dropEffect = "move";
|
||||||
setDragOverIdx(idx);
|
setDragOverIdx(idx);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleDragLeave = useCallback(() => {
|
const handleDragLeave = useCallback(() => {
|
||||||
setDragOverIdx(null);
|
setDragOverIdx(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleDrop = useCallback((toIdx: number, e: React.DragEvent) => {
|
const handleDrop = useCallback((toIdx: number, e: React.DragEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const fromIdx = parseInt(e.dataTransfer.getData("text/plain"), 10);
|
const fromIdx = parseInt(e.dataTransfer.getData("text/plain"), 10);
|
||||||
if (!isNaN(fromIdx) && fromIdx !== toIdx) {
|
if (!isNaN(fromIdx) && fromIdx !== toIdx) {
|
||||||
onReorderSegment(fromIdx, toIdx);
|
onReorderSegment(fromIdx, toIdx);
|
||||||
}
|
}
|
||||||
setDragFromIdx(null);
|
setDragFromIdx(null);
|
||||||
setDragOverIdx(null);
|
setDragOverIdx(null);
|
||||||
}, [onReorderSegment]);
|
}, [onReorderSegment]);
|
||||||
|
|
||||||
const handleDragEnd = useCallback(() => {
|
const handleDragEnd = useCallback(() => {
|
||||||
setDragFromIdx(null);
|
setDragFromIdx(null);
|
||||||
setDragOverIdx(null);
|
setDragOverIdx(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Filter visible vs overflow segments
|
// Filter visible vs overflow segments
|
||||||
const visibleSegments = segments.filter((s) => s.start < audioDuration);
|
const visibleSegments = useMemo(() => segments.filter((s) => s.start < audioDuration), [segments, audioDuration]);
|
||||||
const overflowSegments = segments.filter((s) => s.start >= audioDuration);
|
const overflowSegments = useMemo(() => segments.filter((s) => s.start >= audioDuration), [segments, audioDuration]);
|
||||||
const hasSegments = visibleSegments.length > 0;
|
const hasSegments = visibleSegments.length > 0;
|
||||||
|
|
||||||
return (
|
const content = (
|
||||||
<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-3">
|
<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">
|
{!embedded ? (
|
||||||
🎞️ 时间轴编辑
|
<h2 className="text-base sm:text-lg font-semibold text-white flex items-center gap-2">
|
||||||
</h2>
|
时间轴编辑
|
||||||
|
</h2>
|
||||||
|
) : (
|
||||||
|
<h3 className="text-sm font-medium text-gray-400">时间轴编辑</h3>
|
||||||
|
)}
|
||||||
<div className="flex items-center gap-2 text-xs text-gray-400">
|
<div className="flex items-center gap-2 text-xs text-gray-400">
|
||||||
<div ref={ratioRef} className="relative">
|
<div ref={ratioRef} className="relative">
|
||||||
<button
|
<button
|
||||||
@@ -231,28 +237,28 @@ export function TimelineEditor({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Waveform — always rendered so ref stays mounted */}
|
{/* Waveform — always rendered so ref stays mounted */}
|
||||||
<div className="relative mb-1">
|
<div className="relative mb-1">
|
||||||
<div ref={waveCallbackRef} className="rounded-lg overflow-hidden bg-black/20 cursor-pointer" style={{ minHeight: 56 }} />
|
<div ref={waveCallbackRef} className="rounded-lg overflow-hidden bg-black/20 cursor-pointer" style={{ minHeight: 56 }} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Segment blocks or empty placeholder */}
|
{/* Segment blocks or empty placeholder */}
|
||||||
{hasSegments ? (
|
{hasSegments ? (
|
||||||
<>
|
<>
|
||||||
<div className="relative h-14 flex select-none">
|
<div className="relative h-14 flex select-none">
|
||||||
{/* Playhead — syncs with audio playback */}
|
{/* Playhead — syncs with audio playback */}
|
||||||
<div
|
<div
|
||||||
ref={playheadRef}
|
ref={playheadRef}
|
||||||
className="absolute top-0 h-full w-0.5 bg-fuchsia-400 z-10 pointer-events-none"
|
className="absolute top-0 h-full w-0.5 bg-fuchsia-400 z-10 pointer-events-none"
|
||||||
style={{ display: "none", left: "0%" }}
|
style={{ display: "none", left: "0%" }}
|
||||||
/>
|
/>
|
||||||
{visibleSegments.map((seg, i) => {
|
{visibleSegments.map((seg, i) => {
|
||||||
const left = (seg.start / audioDuration) * 100;
|
const left = (seg.start / audioDuration) * 100;
|
||||||
const width = ((seg.end - seg.start) / audioDuration) * 100;
|
const width = ((seg.end - seg.start) / audioDuration) * 100;
|
||||||
const segDur = seg.end - seg.start;
|
const segDur = seg.end - seg.start;
|
||||||
const isDragTarget = dragOverIdx === i && dragFromIdx !== i;
|
const isDragTarget = dragOverIdx === i && dragFromIdx !== i;
|
||||||
|
|
||||||
// Compute loop portion for the last visible segment
|
// Compute loop portion for the last visible segment
|
||||||
const isLastVisible = i === visibleSegments.length - 1;
|
const isLastVisible = i === visibleSegments.length - 1;
|
||||||
let loopPercent = 0;
|
let loopPercent = 0;
|
||||||
@@ -266,84 +272,93 @@ export function TimelineEditor({
|
|||||||
loopPercent = ((segDur - effDur) / segDur) * 100;
|
loopPercent = ((segDur - effDur) / segDur) * 100;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={seg.id} className="absolute top-0 h-full" style={{ left: `${left}%`, width: `${width}%` }}>
|
<div key={seg.id} className="absolute top-0 h-full" style={{ left: `${left}%`, width: `${width}%` }}>
|
||||||
<button
|
<button
|
||||||
draggable
|
draggable
|
||||||
onDragStart={(e) => handleDragStart(i, e)}
|
onDragStart={(e) => handleDragStart(i, e)}
|
||||||
onDragOver={(e) => handleDragOver(i, e)}
|
onDragOver={(e) => handleDragOver(i, e)}
|
||||||
onDragLeave={handleDragLeave}
|
onDragLeave={handleDragLeave}
|
||||||
onDrop={(e) => handleDrop(i, e)}
|
onDrop={(e) => handleDrop(i, e)}
|
||||||
onDragEnd={handleDragEnd}
|
onDragEnd={handleDragEnd}
|
||||||
onClick={() => onClickSegment(seg)}
|
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 ${
|
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
|
isDragTarget
|
||||||
? "ring-2 ring-purple-400 border-purple-400 scale-[1.02]"
|
? "ring-2 ring-purple-400 border-purple-400 scale-[1.02]"
|
||||||
: dragFromIdx === i
|
: dragFromIdx === i
|
||||||
? "opacity-50 border-white/10"
|
? "opacity-50 border-white/10"
|
||||||
: "hover:opacity-90 border-white/10"
|
: "hover:opacity-90 border-white/10"
|
||||||
}`}
|
}`}
|
||||||
style={{ backgroundColor: seg.color + "33", borderColor: isDragTarget ? undefined : seg.color + "66" }}
|
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)` : ""}`}
|
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]">
|
<GripVertical className="absolute top-0.5 left-0.5 h-3 w-3 text-white/30 z-[1]" />
|
||||||
{seg.materialName}
|
<span className="text-[11px] text-white/90 truncate max-w-full px-1 leading-tight z-[1]">
|
||||||
</span>
|
{seg.materialName}
|
||||||
<span className="text-[10px] text-white/60 leading-tight z-[1]">
|
</span>
|
||||||
{segDur.toFixed(1)}s
|
<span className="text-[10px] text-white/60 leading-tight z-[1]">
|
||||||
</span>
|
{segDur.toFixed(1)}s
|
||||||
{seg.sourceStart > 0 && (
|
</span>
|
||||||
<span className="text-[9px] text-amber-400/80 leading-tight z-[1]">
|
{seg.sourceStart > 0 && (
|
||||||
✂ {seg.sourceStart.toFixed(1)}s
|
<span className="text-[9px] text-amber-400/80 leading-tight z-[1]">
|
||||||
</span>
|
✂ {seg.sourceStart.toFixed(1)}s
|
||||||
)}
|
</span>
|
||||||
{/* Loop fill stripe overlay */}
|
)}
|
||||||
{loopPercent > 0 && (
|
{/* Loop fill stripe overlay */}
|
||||||
<div
|
{loopPercent > 0 && (
|
||||||
className="absolute top-0 right-0 h-full pointer-events-none flex items-center justify-center"
|
<div
|
||||||
style={{
|
className="absolute top-0 right-0 h-full pointer-events-none flex items-center justify-center"
|
||||||
width: `${loopPercent}%`,
|
style={{
|
||||||
background: `repeating-linear-gradient(-45deg, transparent, transparent 3px, rgba(255,255,255,0.07) 3px, rgba(255,255,255,0.07) 6px)`,
|
width: `${loopPercent}%`,
|
||||||
borderLeft: "1px dashed rgba(255,255,255,0.25)",
|
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>
|
<span className="text-[9px] text-white/30">循环</span>
|
||||||
)}
|
</div>
|
||||||
</button>
|
)}
|
||||||
</div>
|
</button>
|
||||||
);
|
</div>
|
||||||
})}
|
);
|
||||||
</div>
|
})}
|
||||||
|
</div>
|
||||||
{/* Overflow segments — shown as gray chips */}
|
|
||||||
{overflowSegments.length > 0 && (
|
{/* Overflow segments — shown as gray chips */}
|
||||||
<div className="flex flex-wrap items-center gap-1.5 mt-1.5">
|
{overflowSegments.length > 0 && (
|
||||||
<span className="text-[10px] text-gray-500">未使用:</span>
|
<div className="flex flex-wrap items-center gap-1.5 mt-1.5">
|
||||||
{overflowSegments.map((seg) => (
|
<span className="text-[10px] text-gray-500">未使用:</span>
|
||||||
<span
|
{overflowSegments.map((seg) => (
|
||||||
key={seg.id}
|
<span
|
||||||
className="text-[10px] text-gray-500 bg-white/5 border border-white/10 rounded px-1.5 py-0.5"
|
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>
|
{seg.materialName}
|
||||||
))}
|
</span>
|
||||||
</div>
|
))}
|
||||||
)}
|
</div>
|
||||||
|
)}
|
||||||
<p className="text-[10px] text-gray-500 mt-1.5">
|
|
||||||
点击波形定位播放 · 拖拽色块调换顺序 · 点击色块设置截取范围
|
<p className="text-[10px] text-gray-500 mt-1.5">
|
||||||
</p>
|
点击波形定位播放 · 拖拽色块调换顺序 · 点击色块设置截取范围
|
||||||
</>
|
</p>
|
||||||
) : (
|
</>
|
||||||
<>
|
) : (
|
||||||
<div className="h-14 bg-white/5 rounded-lg" />
|
<>
|
||||||
<p className="text-[10px] text-gray-500 mt-1.5">
|
<div className="h-14 bg-white/5 rounded-lg" />
|
||||||
选中配音和素材后可编辑时间轴
|
<p className="text-[10px] text-gray-500 mt-1.5">
|
||||||
</p>
|
选中配音和素材后可编辑时间轴
|
||||||
</>
|
</p>
|
||||||
)}
|
</>
|
||||||
</div>
|
)}
|
||||||
);
|
</>
|
||||||
}
|
);
|
||||||
|
|
||||||
|
if (embedded) return content;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white/5 rounded-2xl p-4 sm:p-6 border border-white/10 backdrop-blur-sm">
|
||||||
|
{content}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Eye } from "lucide-react";
|
import { ChevronDown, Eye } from "lucide-react";
|
||||||
import { FloatingStylePreview } from "@/features/home/ui/FloatingStylePreview";
|
import { FloatingStylePreview } from "@/features/home/ui/FloatingStylePreview";
|
||||||
|
|
||||||
interface SubtitleStyleOption {
|
interface SubtitleStyleOption {
|
||||||
@@ -38,11 +38,21 @@ interface TitleSubtitlePanelProps {
|
|||||||
onTitleChange: (value: string) => void;
|
onTitleChange: (value: string) => void;
|
||||||
onTitleCompositionStart?: () => void;
|
onTitleCompositionStart?: () => void;
|
||||||
onTitleCompositionEnd?: (value: string) => void;
|
onTitleCompositionEnd?: (value: string) => void;
|
||||||
|
videoSecondaryTitle: string;
|
||||||
|
onSecondaryTitleChange: (value: string) => void;
|
||||||
|
onSecondaryTitleCompositionStart?: () => void;
|
||||||
|
onSecondaryTitleCompositionEnd?: (value: string) => void;
|
||||||
titleStyles: TitleStyleOption[];
|
titleStyles: TitleStyleOption[];
|
||||||
selectedTitleStyleId: string;
|
selectedTitleStyleId: string;
|
||||||
onSelectTitleStyle: (id: string) => void;
|
onSelectTitleStyle: (id: string) => void;
|
||||||
titleFontSize: number;
|
titleFontSize: number;
|
||||||
onTitleFontSizeChange: (value: number) => void;
|
onTitleFontSizeChange: (value: number) => void;
|
||||||
|
selectedSecondaryTitleStyleId: string;
|
||||||
|
onSelectSecondaryTitleStyle: (id: string) => void;
|
||||||
|
secondaryTitleFontSize: number;
|
||||||
|
onSecondaryTitleFontSizeChange: (value: number) => void;
|
||||||
|
secondaryTitleTopMargin: number;
|
||||||
|
onSecondaryTitleTopMarginChange: (value: number) => void;
|
||||||
subtitleStyles: SubtitleStyleOption[];
|
subtitleStyles: SubtitleStyleOption[];
|
||||||
selectedSubtitleStyleId: string;
|
selectedSubtitleStyleId: string;
|
||||||
onSelectSubtitleStyle: (id: string) => void;
|
onSelectSubtitleStyle: (id: string) => void;
|
||||||
@@ -52,6 +62,8 @@ interface TitleSubtitlePanelProps {
|
|||||||
onTitleTopMarginChange: (value: number) => void;
|
onTitleTopMarginChange: (value: number) => void;
|
||||||
subtitleBottomMargin: number;
|
subtitleBottomMargin: number;
|
||||||
onSubtitleBottomMarginChange: (value: number) => void;
|
onSubtitleBottomMarginChange: (value: number) => void;
|
||||||
|
titleDisplayMode: "short" | "persistent";
|
||||||
|
onTitleDisplayModeChange: (mode: "short" | "persistent") => void;
|
||||||
resolveAssetUrl: (path?: string | null) => string | null;
|
resolveAssetUrl: (path?: string | null) => string | null;
|
||||||
getFontFormat: (fontFile?: string) => string;
|
getFontFormat: (fontFile?: string) => string;
|
||||||
buildTextShadow: (color: string, size: number) => string;
|
buildTextShadow: (color: string, size: number) => string;
|
||||||
@@ -66,11 +78,21 @@ export function TitleSubtitlePanel({
|
|||||||
onTitleChange,
|
onTitleChange,
|
||||||
onTitleCompositionStart,
|
onTitleCompositionStart,
|
||||||
onTitleCompositionEnd,
|
onTitleCompositionEnd,
|
||||||
|
videoSecondaryTitle,
|
||||||
|
onSecondaryTitleChange,
|
||||||
|
onSecondaryTitleCompositionStart,
|
||||||
|
onSecondaryTitleCompositionEnd,
|
||||||
titleStyles,
|
titleStyles,
|
||||||
selectedTitleStyleId,
|
selectedTitleStyleId,
|
||||||
onSelectTitleStyle,
|
onSelectTitleStyle,
|
||||||
titleFontSize,
|
titleFontSize,
|
||||||
onTitleFontSizeChange,
|
onTitleFontSizeChange,
|
||||||
|
selectedSecondaryTitleStyleId,
|
||||||
|
onSelectSecondaryTitleStyle,
|
||||||
|
secondaryTitleFontSize,
|
||||||
|
onSecondaryTitleFontSizeChange,
|
||||||
|
secondaryTitleTopMargin,
|
||||||
|
onSecondaryTitleTopMarginChange,
|
||||||
subtitleStyles,
|
subtitleStyles,
|
||||||
selectedSubtitleStyleId,
|
selectedSubtitleStyleId,
|
||||||
onSelectSubtitleStyle,
|
onSelectSubtitleStyle,
|
||||||
@@ -80,6 +102,8 @@ export function TitleSubtitlePanel({
|
|||||||
onTitleTopMarginChange,
|
onTitleTopMarginChange,
|
||||||
subtitleBottomMargin,
|
subtitleBottomMargin,
|
||||||
onSubtitleBottomMarginChange,
|
onSubtitleBottomMarginChange,
|
||||||
|
titleDisplayMode,
|
||||||
|
onTitleDisplayModeChange,
|
||||||
resolveAssetUrl,
|
resolveAssetUrl,
|
||||||
getFontFormat,
|
getFontFormat,
|
||||||
buildTextShadow,
|
buildTextShadow,
|
||||||
@@ -90,24 +114,42 @@ export function TitleSubtitlePanel({
|
|||||||
<div className="bg-white/5 rounded-2xl p-4 sm:p-6 border border-white/10 backdrop-blur-sm">
|
<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">
|
<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 className="text-base sm:text-lg font-semibold text-white flex items-center gap-2">
|
||||||
🎬 标题与字幕
|
二、标题与字幕
|
||||||
</h2>
|
</h2>
|
||||||
<button
|
<div className="flex items-center gap-1.5">
|
||||||
onClick={onTogglePreview}
|
<div className="relative shrink-0">
|
||||||
className="px-2 py-1 text-xs bg-white/10 hover:bg-white/20 rounded text-gray-300 flex items-center gap-1"
|
<select
|
||||||
>
|
value={titleDisplayMode}
|
||||||
<Eye className="h-3.5 w-3.5" />
|
onChange={(e) => onTitleDisplayModeChange(e.target.value as "short" | "persistent")}
|
||||||
{showStylePreview ? "收起预览" : "预览样式"}
|
className="appearance-none rounded-lg border border-white/15 bg-black/35 px-2.5 py-1.5 pr-7 text-xs text-gray-200 outline-none transition-colors hover:border-white/25 focus:border-purple-500"
|
||||||
</button>
|
aria-label="标题显示方式"
|
||||||
|
>
|
||||||
|
<option value="short">标题短暂显示</option>
|
||||||
|
<option value="persistent">标题常驻显示</option>
|
||||||
|
</select>
|
||||||
|
<ChevronDown className="pointer-events-none absolute right-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-gray-400" />
|
||||||
|
</div>
|
||||||
|
<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 && (
|
{showStylePreview && (
|
||||||
<FloatingStylePreview
|
<FloatingStylePreview
|
||||||
onClose={onTogglePreview}
|
onClose={onTogglePreview}
|
||||||
videoTitle={videoTitle}
|
videoTitle={videoTitle}
|
||||||
|
videoSecondaryTitle={videoSecondaryTitle}
|
||||||
titleStyles={titleStyles}
|
titleStyles={titleStyles}
|
||||||
selectedTitleStyleId={selectedTitleStyleId}
|
selectedTitleStyleId={selectedTitleStyleId}
|
||||||
titleFontSize={titleFontSize}
|
titleFontSize={titleFontSize}
|
||||||
|
selectedSecondaryTitleStyleId={selectedSecondaryTitleStyleId}
|
||||||
|
secondaryTitleFontSize={secondaryTitleFontSize}
|
||||||
|
secondaryTitleTopMargin={secondaryTitleTopMargin}
|
||||||
subtitleStyles={subtitleStyles}
|
subtitleStyles={subtitleStyles}
|
||||||
selectedSubtitleStyleId={selectedSubtitleStyleId}
|
selectedSubtitleStyleId={selectedSubtitleStyleId}
|
||||||
subtitleFontSize={subtitleFontSize}
|
subtitleFontSize={subtitleFontSize}
|
||||||
@@ -123,7 +165,10 @@ export function TitleSubtitlePanel({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="mb-4">
|
<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
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={videoTitle}
|
value={videoTitle}
|
||||||
@@ -135,96 +180,102 @@ export function TitleSubtitlePanel({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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 && (
|
{titleStyles.length > 0 && (
|
||||||
<div className="mb-4">
|
<div className="mb-4 space-y-3">
|
||||||
<label className="text-sm text-gray-300 mb-2 block">标题样式</label>
|
<div className="flex items-center gap-3">
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<label className="text-sm text-gray-300 shrink-0 w-20">标题样式</label>
|
||||||
{titleStyles.map((style) => (
|
<div className="relative w-1/3 min-w-[100px]">
|
||||||
<button
|
<select
|
||||||
key={style.id}
|
value={selectedTitleStyleId}
|
||||||
onClick={() => onSelectTitleStyle(style.id)}
|
onChange={(e) => onSelectTitleStyle(e.target.value)}
|
||||||
className={`p-2 rounded-lg border transition-all text-left ${selectedTitleStyleId === style.id
|
className="w-full appearance-none rounded-lg border border-white/15 bg-black/35 px-3 py-2 pr-8 text-sm text-gray-200 outline-none transition-colors hover:border-white/25 focus:border-purple-500"
|
||||||
? "border-purple-500 bg-purple-500/20"
|
|
||||||
: "border-white/10 bg-white/5 hover:border-white/30"
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
<div className="text-white text-sm truncate">{style.label}</div>
|
{titleStyles.map((style) => (
|
||||||
<div className="text-xs text-gray-400 truncate">
|
<option key={style.id} value={style.id}>{style.label}</option>
|
||||||
{style.font_family || style.font_file || ""}
|
))}
|
||||||
</div>
|
</select>
|
||||||
</button>
|
<ChevronDown className="pointer-events-none absolute right-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-gray-400" />
|
||||||
))}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3">
|
<div className="flex items-center gap-3">
|
||||||
<label className="text-xs text-gray-400 mb-2 block">标题字号: {titleFontSize}px</label>
|
<label className="text-xs text-gray-400 shrink-0 w-20">字号 {titleFontSize}</label>
|
||||||
<input
|
<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" />
|
||||||
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>
|
</div>
|
||||||
<div className="mt-3">
|
<div className="flex items-center gap-3">
|
||||||
<label className="text-xs text-gray-400 mb-2 block">标题位置: {titleTopMargin}px</label>
|
<label className="text-xs text-gray-400 shrink-0 w-20">位置 {titleTopMargin}</label>
|
||||||
<input
|
<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" />
|
||||||
type="range"
|
</div>
|
||||||
min="0"
|
</div>
|
||||||
max="300"
|
)}
|
||||||
step="1"
|
|
||||||
value={titleTopMargin}
|
{titleStyles.length > 0 && (
|
||||||
onChange={(e) => onTitleTopMarginChange(parseInt(e.target.value, 10))}
|
<div className="mb-4 space-y-3">
|
||||||
className="w-full accent-purple-500"
|
<div className="flex items-center gap-3">
|
||||||
/>
|
<label className="text-sm text-gray-300 shrink-0 w-20">副标题样式</label>
|
||||||
|
<div className="relative w-1/3 min-w-[100px]">
|
||||||
|
<select
|
||||||
|
value={selectedSecondaryTitleStyleId}
|
||||||
|
onChange={(e) => onSelectSecondaryTitleStyle(e.target.value)}
|
||||||
|
className="w-full appearance-none rounded-lg border border-white/15 bg-black/35 px-3 py-2 pr-8 text-sm text-gray-200 outline-none transition-colors hover:border-white/25 focus:border-purple-500"
|
||||||
|
>
|
||||||
|
{titleStyles.map((style) => (
|
||||||
|
<option key={style.id} value={style.id}>{style.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<ChevronDown className="pointer-events-none absolute right-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-gray-400" />
|
||||||
|
</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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{subtitleStyles.length > 0 && (
|
{subtitleStyles.length > 0 && (
|
||||||
<div className="mt-4">
|
<div className="mt-4 space-y-3">
|
||||||
<label className="text-sm text-gray-300 mb-2 block">字幕样式</label>
|
<div className="flex items-center gap-3">
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<label className="text-sm text-gray-300 shrink-0 w-20">字幕样式</label>
|
||||||
{subtitleStyles.map((style) => (
|
<div className="relative w-1/3 min-w-[100px]">
|
||||||
<button
|
<select
|
||||||
key={style.id}
|
value={selectedSubtitleStyleId}
|
||||||
onClick={() => onSelectSubtitleStyle(style.id)}
|
onChange={(e) => onSelectSubtitleStyle(e.target.value)}
|
||||||
className={`p-2 rounded-lg border transition-all text-left ${selectedSubtitleStyleId === style.id
|
className="w-full appearance-none rounded-lg border border-white/15 bg-black/35 px-3 py-2 pr-8 text-sm text-gray-200 outline-none transition-colors hover:border-white/25 focus:border-purple-500"
|
||||||
? "border-purple-500 bg-purple-500/20"
|
|
||||||
: "border-white/10 bg-white/5 hover:border-white/30"
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
<div className="text-white text-sm truncate">{style.label}</div>
|
{subtitleStyles.map((style) => (
|
||||||
<div className="text-xs text-gray-400 truncate">
|
<option key={style.id} value={style.id}>{style.label}</option>
|
||||||
{style.font_family || style.font_file || ""}
|
))}
|
||||||
</div>
|
</select>
|
||||||
</button>
|
<ChevronDown className="pointer-events-none absolute right-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-gray-400" />
|
||||||
))}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3">
|
<div className="flex items-center gap-3">
|
||||||
<label className="text-xs text-gray-400 mb-2 block">字幕字号: {subtitleFontSize}px</label>
|
<label className="text-xs text-gray-400 shrink-0 w-20">字号 {subtitleFontSize}</label>
|
||||||
<input
|
<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" />
|
||||||
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>
|
</div>
|
||||||
<div className="mt-3">
|
<div className="flex items-center gap-3">
|
||||||
<label className="text-xs text-gray-400 mb-2 block">字幕位置: {subtitleBottomMargin}px</label>
|
<label className="text-xs text-gray-400 shrink-0 w-20">位置 {subtitleBottomMargin}</label>
|
||||||
<input
|
<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" />
|
||||||
type="range"
|
|
||||||
min="0"
|
|
||||||
max="300"
|
|
||||||
step="1"
|
|
||||||
value={subtitleBottomMargin}
|
|
||||||
onChange={(e) => onSubtitleBottomMarginChange(parseInt(e.target.value, 10))}
|
|
||||||
className="w-full accent-purple-500"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ interface VoiceSelectorProps {
|
|||||||
voice: string;
|
voice: string;
|
||||||
onSelectVoice: (id: string) => void;
|
onSelectVoice: (id: string) => void;
|
||||||
voiceCloneSlot: ReactNode;
|
voiceCloneSlot: ReactNode;
|
||||||
|
embedded?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function VoiceSelector({
|
export function VoiceSelector({
|
||||||
@@ -22,32 +23,29 @@ export function VoiceSelector({
|
|||||||
voice,
|
voice,
|
||||||
onSelectVoice,
|
onSelectVoice,
|
||||||
voiceCloneSlot,
|
voiceCloneSlot,
|
||||||
|
embedded = false,
|
||||||
}: VoiceSelectorProps) {
|
}: VoiceSelectorProps) {
|
||||||
return (
|
const content = (
|
||||||
<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="flex gap-2 mb-4">
|
<div className="flex gap-2 mb-4">
|
||||||
<button
|
<button
|
||||||
onClick={() => onSelectTtsMode("edgetts")}
|
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-purple-600 text-white"
|
||||||
: "bg-white/10 text-gray-300 hover:bg-white/20"
|
: "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>
|
||||||
<button
|
<button
|
||||||
onClick={() => onSelectTtsMode("voiceclone")}
|
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-purple-600 text-white"
|
||||||
: "bg-white/10 text-gray-300 hover:bg-white/20"
|
: "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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -70,6 +68,17 @@ export function VoiceSelector({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{ttsMode === "voiceclone" && voiceCloneSlot}
|
{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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useCallback } from "react";
|
import { useState, useEffect, useCallback, useRef } from "react";
|
||||||
import api from "@/shared/api/axios";
|
import api from "@/shared/api/axios";
|
||||||
import { ApiResponse, unwrap } from "@/shared/api/types";
|
import { ApiResponse, unwrap } from "@/shared/api/types";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -7,6 +7,7 @@ export type ExtractionStep = "config" | "processing" | "result";
|
|||||||
export type InputTab = "file" | "url";
|
export type InputTab = "file" | "url";
|
||||||
|
|
||||||
const VALID_FILE_TYPES = [".mp4", ".mov", ".avi", ".mp3", ".wav", ".m4a"];
|
const VALID_FILE_TYPES = [".mp4", ".mov", ".avi", ".mp3", ".wav", ".m4a"];
|
||||||
|
const CUSTOM_PROMPT_KEY = "vigent_rewriteCustomPrompt";
|
||||||
|
|
||||||
interface UseScriptExtractionOptions {
|
interface UseScriptExtractionOptions {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -23,8 +24,19 @@ export const useScriptExtraction = ({ isOpen }: UseScriptExtractionOptions) => {
|
|||||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||||
const [activeTab, setActiveTab] = useState<InputTab>("url");
|
const [activeTab, setActiveTab] = useState<InputTab>("url");
|
||||||
const [inputUrl, setInputUrl] = useState("");
|
const [inputUrl, setInputUrl] = useState("");
|
||||||
|
const [customPrompt, setCustomPrompt] = useState(() => typeof window !== "undefined" ? localStorage.getItem(CUSTOM_PROMPT_KEY) || "" : "");
|
||||||
|
const [showCustomPrompt, setShowCustomPrompt] = useState(false);
|
||||||
|
|
||||||
// Reset state when modal opens
|
// 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 (customPrompt is persistent, not reset)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
setStep("config");
|
setStep("config");
|
||||||
@@ -101,6 +113,9 @@ export const useScriptExtraction = ({ isOpen }: UseScriptExtractionOptions) => {
|
|||||||
formData.append("url", inputUrl.trim());
|
formData.append("url", inputUrl.trim());
|
||||||
}
|
}
|
||||||
formData.append("rewrite", doRewrite ? "true" : "false");
|
formData.append("rewrite", doRewrite ? "true" : "false");
|
||||||
|
if (doRewrite && customPrompt.trim()) {
|
||||||
|
formData.append("custom_prompt", customPrompt.trim());
|
||||||
|
}
|
||||||
|
|
||||||
const { data: res } = await api.post<
|
const { data: res } = await api.post<
|
||||||
ApiResponse<{ original_script: string; rewritten_script?: string }>
|
ApiResponse<{ original_script: string; rewritten_script?: string }>
|
||||||
@@ -126,7 +141,7 @@ export const useScriptExtraction = ({ isOpen }: UseScriptExtractionOptions) => {
|
|||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
}, [activeTab, selectedFile, inputUrl, doRewrite]);
|
}, [activeTab, selectedFile, inputUrl, doRewrite, customPrompt]);
|
||||||
|
|
||||||
const copyToClipboard = useCallback((text: string) => {
|
const copyToClipboard = useCallback((text: string) => {
|
||||||
if (navigator.clipboard && window.isSecureContext) {
|
if (navigator.clipboard && window.isSecureContext) {
|
||||||
@@ -193,10 +208,14 @@ export const useScriptExtraction = ({ isOpen }: UseScriptExtractionOptions) => {
|
|||||||
selectedFile,
|
selectedFile,
|
||||||
activeTab,
|
activeTab,
|
||||||
inputUrl,
|
inputUrl,
|
||||||
|
customPrompt,
|
||||||
|
showCustomPrompt,
|
||||||
// Setters
|
// Setters
|
||||||
setDoRewrite,
|
setDoRewrite,
|
||||||
setActiveTab,
|
setActiveTab,
|
||||||
setInputUrl,
|
setInputUrl,
|
||||||
|
setCustomPrompt,
|
||||||
|
setShowCustomPrompt,
|
||||||
// Handlers
|
// Handlers
|
||||||
handleDrag,
|
handleDrag,
|
||||||
handleDrop,
|
handleDrop,
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ export function PublishPage() {
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="bg-white/5 rounded-2xl p-6 border border-white/10 backdrop-blur-sm">
|
<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 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
|
||||||
👤 平台账号
|
七、平台账号
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
{isAccountsLoading ? (
|
{isAccountsLoading ? (
|
||||||
@@ -157,62 +157,60 @@ export function PublishPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-2 sm:space-y-3">
|
||||||
{accounts.map((account) => (
|
{accounts.map((account) => (
|
||||||
<div
|
<div
|
||||||
key={account.platform}
|
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] ? (
|
||||||
{platformIcons[account.platform] ? (
|
<Image
|
||||||
<Image
|
src={platformIcons[account.platform].src}
|
||||||
src={platformIcons[account.platform].src}
|
alt={platformIcons[account.platform].alt}
|
||||||
alt={platformIcons[account.platform].alt}
|
width={28}
|
||||||
width={28}
|
height={28}
|
||||||
height={28}
|
className="h-6 w-6 sm:h-7 sm:w-7 shrink-0"
|
||||||
className="h-7 w-7"
|
/>
|
||||||
/>
|
) : (
|
||||||
) : (
|
<span className="text-xl sm:text-2xl">🌐</span>
|
||||||
<span className="text-2xl">🌐</span>
|
)}
|
||||||
)}
|
<div className="min-w-0 flex-1">
|
||||||
<div>
|
<div className="text-sm sm:text-base text-white font-medium leading-tight">
|
||||||
<div className="text-white font-medium">
|
{account.name}
|
||||||
{account.name}
|
</div>
|
||||||
</div>
|
<div
|
||||||
<div
|
className={`text-xs sm:text-sm leading-tight ${account.logged_in
|
||||||
className={`text-sm ${account.logged_in
|
? "text-green-400"
|
||||||
? "text-green-400"
|
: "text-gray-500"
|
||||||
: "text-gray-500"
|
}`}
|
||||||
}`}
|
>
|
||||||
>
|
{account.logged_in ? "✓ 已登录" : "未登录"}
|
||||||
{account.logged_in ? "✓ 已登录" : "未登录"}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex items-center gap-1.5 sm:gap-2 shrink-0">
|
||||||
{account.logged_in ? (
|
{account.logged_in ? (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleLogin(account.platform)}
|
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>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleLogout(account.platform)}
|
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>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
onClick={() => handleLogin(account.platform)}
|
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>
|
</button>
|
||||||
)}
|
)}
|
||||||
@@ -228,7 +226,7 @@ export function PublishPage() {
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* 选择视频 */}
|
{/* 选择视频 */}
|
||||||
<div className="bg-white/5 rounded-2xl p-6 border border-white/10 backdrop-blur-sm">
|
<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="flex items-center gap-3 mb-4">
|
<div className="flex items-center gap-3 mb-4">
|
||||||
<Search className="text-gray-400 w-4 h-4" />
|
<Search className="text-gray-400 w-4 h-4" />
|
||||||
@@ -303,7 +301,7 @@ export function PublishPage() {
|
|||||||
|
|
||||||
{/* 填写信息 */}
|
{/* 填写信息 */}
|
||||||
<div className="bg-white/5 rounded-2xl p-6 border border-white/10 backdrop-blur-sm">
|
<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 className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
@@ -337,7 +335,7 @@ export function PublishPage() {
|
|||||||
|
|
||||||
{/* 选择平台 */}
|
{/* 选择平台 */}
|
||||||
<div className="bg-white/5 rounded-2xl p-6 border border-white/10 backdrop-blur-sm">
|
<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">
|
<div className="grid grid-cols-3 gap-3">
|
||||||
{accounts
|
{accounts
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ const API_BASE = typeof window === 'undefined'
|
|||||||
// 防止重复跳转
|
// 防止重复跳转
|
||||||
let isRedirecting = false;
|
let isRedirecting = false;
|
||||||
|
|
||||||
const PUBLIC_PATHS = new Set(['/login', '/register']);
|
const PUBLIC_PATHS = new Set(['/login', '/register', '/pay']);
|
||||||
|
|
||||||
// 创建 axios 实例
|
// 创建 axios 实例
|
||||||
const api = axios.create({
|
const api = axios.create({
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ interface AuthContextType {
|
|||||||
user: User | null;
|
user: User | null;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
isAuthenticated: boolean;
|
isAuthenticated: boolean;
|
||||||
|
setUser: (user: User | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const AuthContext = createContext<AuthContextType>({
|
const AuthContext = createContext<AuthContextType>({
|
||||||
@@ -18,6 +19,7 @@ const AuthContext = createContext<AuthContextType>({
|
|||||||
user: null,
|
user: null,
|
||||||
isLoading: true,
|
isLoading: true,
|
||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
|
setUser: () => {},
|
||||||
});
|
});
|
||||||
|
|
||||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
@@ -63,7 +65,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
userId: user?.id || null,
|
userId: user?.id || null,
|
||||||
user,
|
user,
|
||||||
isLoading,
|
isLoading,
|
||||||
isAuthenticated: !!user
|
isAuthenticated: !!user,
|
||||||
|
setUser,
|
||||||
}}>
|
}}>
|
||||||
{children}
|
{children}
|
||||||
</AuthContext.Provider>
|
</AuthContext.Provider>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export interface AuthResponse {
|
|||||||
success: boolean;
|
success: boolean;
|
||||||
message: string;
|
message: string;
|
||||||
user?: User;
|
user?: User;
|
||||||
|
paymentToken?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ApiResponse<T> {
|
interface ApiResponse<T> {
|
||||||
@@ -25,20 +26,41 @@ interface ApiResponse<T> {
|
|||||||
* 用户注册
|
* 用户注册
|
||||||
*/
|
*/
|
||||||
export async function register(phone: string, password: string, username?: string): Promise<AuthResponse> {
|
export async function register(phone: string, password: string, username?: string): Promise<AuthResponse> {
|
||||||
const { data: payload } = await api.post<ApiResponse<null>>('/api/auth/register', {
|
try {
|
||||||
phone, password, username
|
const { data: payload } = await api.post<ApiResponse<null>>('/api/auth/register', {
|
||||||
});
|
phone, password, username
|
||||||
return { success: payload.success, message: payload.message };
|
});
|
||||||
|
return { success: payload.success, message: payload.message };
|
||||||
|
} catch (err: any) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: err.response?.data?.message || '注册失败',
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 用户登录
|
* 用户登录
|
||||||
*/
|
*/
|
||||||
export async function login(phone: string, password: string): Promise<AuthResponse> {
|
export async function login(phone: string, password: string): Promise<AuthResponse> {
|
||||||
const { data: payload } = await api.post<ApiResponse<{ user?: User }>>('/api/auth/login', {
|
try {
|
||||||
phone, password
|
const { data: payload } = await api.post<ApiResponse<{ user?: User }>>('/api/auth/login', {
|
||||||
});
|
phone, password
|
||||||
return { success: payload.success, message: payload.message, user: payload.data?.user };
|
});
|
||||||
|
return { success: payload.success, message: payload.message, user: payload.data?.user };
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err.response?.status === 403 && err.response?.data?.data?.reason === 'PAYMENT_REQUIRED') {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: err.response.data.message,
|
||||||
|
paymentToken: err.response.data.data.payment_token,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: err.response?.data?.message || '登录失败',
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
export const TITLE_MAX_LENGTH = 15;
|
export const TITLE_MAX_LENGTH = 15;
|
||||||
|
export const SECONDARY_TITLE_MAX_LENGTH = 20;
|
||||||
|
|
||||||
export const clampTitle = (value: string, maxLength: number = TITLE_MAX_LENGTH) =>
|
export const clampTitle = (value: string, maxLength: number = TITLE_MAX_LENGTH) =>
|
||||||
value.slice(0, maxLength);
|
value.slice(0, maxLength);
|
||||||
|
|
||||||
|
export const clampSecondaryTitle = (value: string, maxLength: number = SECONDARY_TITLE_MAX_LENGTH) =>
|
||||||
|
value.slice(0, maxLength);
|
||||||
|
|
||||||
export const applyTitleLimit = (
|
export const applyTitleLimit = (
|
||||||
prev: string,
|
prev: string,
|
||||||
next: string,
|
next: string,
|
||||||
|
|||||||
@@ -16,8 +16,11 @@ interface RenderOptions {
|
|||||||
captionsPath?: string;
|
captionsPath?: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
titleDuration?: number;
|
titleDuration?: number;
|
||||||
|
titleDisplayMode?: 'short' | 'persistent';
|
||||||
subtitleStyle?: Record<string, unknown>;
|
subtitleStyle?: Record<string, unknown>;
|
||||||
titleStyle?: Record<string, unknown>;
|
titleStyle?: Record<string, unknown>;
|
||||||
|
secondaryTitle?: string;
|
||||||
|
secondaryTitleStyle?: Record<string, unknown>;
|
||||||
outputPath: string;
|
outputPath: string;
|
||||||
fps?: number;
|
fps?: number;
|
||||||
enableSubtitles?: boolean;
|
enableSubtitles?: boolean;
|
||||||
@@ -46,6 +49,11 @@ async function parseArgs(): Promise<RenderOptions> {
|
|||||||
case 'titleDuration':
|
case 'titleDuration':
|
||||||
options.titleDuration = parseFloat(value);
|
options.titleDuration = parseFloat(value);
|
||||||
break;
|
break;
|
||||||
|
case 'titleDisplayMode':
|
||||||
|
if (value === 'short' || value === 'persistent') {
|
||||||
|
options.titleDisplayMode = value;
|
||||||
|
}
|
||||||
|
break;
|
||||||
case 'output':
|
case 'output':
|
||||||
options.outputPath = value;
|
options.outputPath = value;
|
||||||
break;
|
break;
|
||||||
@@ -69,6 +77,16 @@ async function parseArgs(): Promise<RenderOptions> {
|
|||||||
console.warn('Invalid titleStyle JSON');
|
console.warn('Invalid titleStyle JSON');
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
case 'secondaryTitle':
|
||||||
|
options.secondaryTitle = value;
|
||||||
|
break;
|
||||||
|
case 'secondaryTitleStyle':
|
||||||
|
try {
|
||||||
|
options.secondaryTitleStyle = JSON.parse(value);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Invalid secondaryTitleStyle JSON');
|
||||||
|
}
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,9 +169,12 @@ async function main() {
|
|||||||
videoSrc: videoFileName,
|
videoSrc: videoFileName,
|
||||||
captions,
|
captions,
|
||||||
title: options.title,
|
title: options.title,
|
||||||
titleDuration: options.titleDuration || 3,
|
titleDuration: options.titleDuration || 4,
|
||||||
|
titleDisplayMode: options.titleDisplayMode || 'short',
|
||||||
subtitleStyle: options.subtitleStyle,
|
subtitleStyle: options.subtitleStyle,
|
||||||
titleStyle: options.titleStyle,
|
titleStyle: options.titleStyle,
|
||||||
|
secondaryTitle: options.secondaryTitle,
|
||||||
|
secondaryTitleStyle: options.secondaryTitleStyle,
|
||||||
enableSubtitles: options.enableSubtitles !== false,
|
enableSubtitles: options.enableSubtitles !== false,
|
||||||
width: videoWidth,
|
width: videoWidth,
|
||||||
height: videoHeight,
|
height: videoHeight,
|
||||||
|
|||||||
@@ -25,8 +25,11 @@ export const RemotionRoot: React.FC = () => {
|
|||||||
audioSrc: undefined,
|
audioSrc: undefined,
|
||||||
captions: undefined,
|
captions: undefined,
|
||||||
title: undefined,
|
title: undefined,
|
||||||
titleDuration: 3,
|
secondaryTitle: undefined,
|
||||||
|
titleDuration: 4,
|
||||||
|
titleDisplayMode: 'short',
|
||||||
enableSubtitles: true,
|
enableSubtitles: true,
|
||||||
|
secondaryTitleStyle: undefined,
|
||||||
width: 1080,
|
width: 1080,
|
||||||
height: 1920,
|
height: 1920,
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -10,10 +10,13 @@ export interface VideoProps {
|
|||||||
audioSrc?: string;
|
audioSrc?: string;
|
||||||
captions?: CaptionsData;
|
captions?: CaptionsData;
|
||||||
title?: string;
|
title?: string;
|
||||||
|
secondaryTitle?: string;
|
||||||
titleDuration?: number;
|
titleDuration?: number;
|
||||||
|
titleDisplayMode?: 'short' | 'persistent';
|
||||||
enableSubtitles?: boolean;
|
enableSubtitles?: boolean;
|
||||||
subtitleStyle?: SubtitleStyle;
|
subtitleStyle?: SubtitleStyle;
|
||||||
titleStyle?: TitleStyle;
|
titleStyle?: TitleStyle;
|
||||||
|
secondaryTitleStyle?: TitleStyle;
|
||||||
width?: number;
|
width?: number;
|
||||||
height?: number;
|
height?: number;
|
||||||
}
|
}
|
||||||
@@ -27,10 +30,13 @@ export const Video: React.FC<VideoProps> = ({
|
|||||||
audioSrc,
|
audioSrc,
|
||||||
captions,
|
captions,
|
||||||
title,
|
title,
|
||||||
titleDuration = 3,
|
secondaryTitle,
|
||||||
|
titleDuration = 4,
|
||||||
|
titleDisplayMode = 'short',
|
||||||
enableSubtitles = true,
|
enableSubtitles = true,
|
||||||
subtitleStyle,
|
subtitleStyle,
|
||||||
titleStyle,
|
titleStyle,
|
||||||
|
secondaryTitleStyle,
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<AbsoluteFill style={{ backgroundColor: 'black' }}>
|
<AbsoluteFill style={{ backgroundColor: 'black' }}>
|
||||||
@@ -43,8 +49,15 @@ export const Video: React.FC<VideoProps> = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 顶层:标题 */}
|
{/* 顶层:标题 */}
|
||||||
{title && (
|
{(title || secondaryTitle) && (
|
||||||
<Title title={title} duration={titleDuration} style={titleStyle} />
|
<Title
|
||||||
|
title={title || ''}
|
||||||
|
secondaryTitle={secondaryTitle}
|
||||||
|
duration={titleDuration}
|
||||||
|
displayMode={titleDisplayMode}
|
||||||
|
style={titleStyle}
|
||||||
|
secondaryTitleStyle={secondaryTitleStyle}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</AbsoluteFill>
|
</AbsoluteFill>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -25,9 +25,12 @@ export interface TitleStyle {
|
|||||||
|
|
||||||
interface TitleProps {
|
interface TitleProps {
|
||||||
title: string;
|
title: string;
|
||||||
|
secondaryTitle?: string;
|
||||||
duration?: number; // 标题显示时长(秒)
|
duration?: number; // 标题显示时长(秒)
|
||||||
|
displayMode?: 'short' | 'persistent'; // 短暂显示或常驻显示
|
||||||
fadeOutStart?: number; // 开始淡出的时间(秒)
|
fadeOutStart?: number; // 开始淡出的时间(秒)
|
||||||
style?: TitleStyle;
|
style?: TitleStyle;
|
||||||
|
secondaryTitleStyle?: TitleStyle;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -47,24 +50,27 @@ const buildTextShadow = (color: string, size: number) => {
|
|||||||
`${size}px -${size}px 0 ${color}`,
|
`${size}px -${size}px 0 ${color}`,
|
||||||
`-${size}px ${size}px 0 ${color}`,
|
`-${size}px ${size}px 0 ${color}`,
|
||||||
`${size}px ${size}px 0 ${color}`,
|
`${size}px ${size}px 0 ${color}`,
|
||||||
`0 0 ${size * 4}px rgba(0,0,0,0.9)`,
|
`0 0 ${size * 2}px rgba(0,0,0,0.5)`,
|
||||||
`0 4px 8px rgba(0,0,0,0.6)`
|
`0 2px 4px rgba(0,0,0,0.3)`
|
||||||
].join(',');
|
].join(',');
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Title: React.FC<TitleProps> = ({
|
export const Title: React.FC<TitleProps> = ({
|
||||||
title,
|
title,
|
||||||
duration = 3,
|
secondaryTitle,
|
||||||
fadeOutStart = 2,
|
duration = 4,
|
||||||
|
displayMode = 'short',
|
||||||
|
fadeOutStart,
|
||||||
style,
|
style,
|
||||||
|
secondaryTitleStyle,
|
||||||
}) => {
|
}) => {
|
||||||
const frame = useCurrentFrame();
|
const frame = useCurrentFrame();
|
||||||
const { fps, width } = useVideoConfig();
|
const { fps, width } = useVideoConfig();
|
||||||
|
|
||||||
const currentTimeInSeconds = frame / fps;
|
const currentTimeInSeconds = frame / fps;
|
||||||
|
|
||||||
// 如果超过显示时长,不渲染
|
// 短暂显示:超过设定时长后不再渲染;常驻显示:全程保留
|
||||||
if (currentTimeInSeconds > duration) {
|
if (displayMode === 'short' && currentTimeInSeconds > duration) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,14 +82,22 @@ export const Title: React.FC<TitleProps> = ({
|
|||||||
{ extrapolateRight: 'clamp' }
|
{ extrapolateRight: 'clamp' }
|
||||||
);
|
);
|
||||||
|
|
||||||
// 淡出效果
|
const defaultFadeOutStart = Math.max(duration - 1, 0.5);
|
||||||
const fadeOutOpacity = interpolate(
|
const effectiveFadeOutStart = Math.max(
|
||||||
currentTimeInSeconds,
|
0.1,
|
||||||
[fadeOutStart, duration],
|
Math.min(fadeOutStart ?? defaultFadeOutStart, duration - 0.05)
|
||||||
[1, 0],
|
|
||||||
{ extrapolateLeft: 'clamp', extrapolateRight: 'clamp' }
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 淡出效果(仅短暂显示模式生效)
|
||||||
|
const fadeOutOpacity = displayMode === 'persistent'
|
||||||
|
? 1
|
||||||
|
: interpolate(
|
||||||
|
currentTimeInSeconds,
|
||||||
|
[effectiveFadeOutStart, duration],
|
||||||
|
[1, 0],
|
||||||
|
{ extrapolateLeft: 'clamp', extrapolateRight: 'clamp' }
|
||||||
|
);
|
||||||
|
|
||||||
const opacity = Math.min(fadeInOpacity, fadeOutOpacity);
|
const opacity = Math.min(fadeInOpacity, fadeOutOpacity);
|
||||||
|
|
||||||
// 轻微的下滑动画
|
// 轻微的下滑动画
|
||||||
@@ -120,9 +134,32 @@ export const Title: React.FC<TitleProps> = ({
|
|||||||
? `'${fontFamilyName}'`
|
? `'${fontFamilyName}'`
|
||||||
: '"PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans SC", sans-serif';
|
: '"PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans SC", sans-serif';
|
||||||
|
|
||||||
|
// 副标题样式
|
||||||
|
const stStyle = secondaryTitleStyle || style;
|
||||||
|
const stFontFile = secondaryTitleStyle?.font_file ?? style?.font_file;
|
||||||
|
const stFontFamily = secondaryTitleStyle?.font_family ?? style?.font_family;
|
||||||
|
const stBaseFontSize = stStyle?.font_size ?? 48;
|
||||||
|
const stBaseStrokeSize = stStyle?.stroke_size ?? 3;
|
||||||
|
const stBaseLetterSpacing = stStyle?.letter_spacing ?? 2;
|
||||||
|
const stBaseTopMargin = secondaryTitleStyle?.top_margin;
|
||||||
|
const stFontSize = Math.max(24, Math.round(stBaseFontSize * responsiveScale));
|
||||||
|
const stColor = stStyle?.color ?? '#FFFFFF';
|
||||||
|
const stStrokeColor = stStyle?.stroke_color ?? '#000000';
|
||||||
|
const stStrokeSize = Math.max(1, Math.round(stBaseStrokeSize * responsiveScale));
|
||||||
|
const stLetterSpacing = Math.max(0, stBaseLetterSpacing * responsiveScale);
|
||||||
|
const stFontWeight = stStyle?.font_weight ?? 700;
|
||||||
|
const stFontFamilyName = stFontFamily || 'SecondaryTitleFont';
|
||||||
|
const stFontFamilyCss = stFontFile
|
||||||
|
? `'${stFontFamilyName}'`
|
||||||
|
: '"PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans SC", sans-serif';
|
||||||
|
const stMarginTop = typeof stBaseTopMargin === 'number'
|
||||||
|
? Math.max(0, Math.round(stBaseTopMargin * responsiveScale))
|
||||||
|
: Math.round(12 * responsiveScale);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AbsoluteFill
|
<AbsoluteFill
|
||||||
style={{
|
style={{
|
||||||
|
flexDirection: 'column',
|
||||||
justifyContent: 'flex-start',
|
justifyContent: 'flex-start',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
paddingTop: typeof topMargin === 'number' ? `${topMargin}px` : '6%',
|
paddingTop: typeof topMargin === 'number' ? `${topMargin}px` : '6%',
|
||||||
@@ -139,6 +176,16 @@ export const Title: React.FC<TitleProps> = ({
|
|||||||
}
|
}
|
||||||
`}</style>
|
`}</style>
|
||||||
)}
|
)}
|
||||||
|
{secondaryTitle && stFontFile && stFontFile !== fontFile && (
|
||||||
|
<style>{`
|
||||||
|
@font-face {
|
||||||
|
font-family: '${stFontFamilyName}';
|
||||||
|
src: url('${staticFile(stFontFile)}') format('${getFontFormat(stFontFile)}');
|
||||||
|
font-weight: 400;
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
)}
|
||||||
<h1
|
<h1
|
||||||
style={{
|
style={{
|
||||||
transform: `translateY(${translateY}px)`,
|
transform: `translateY(${translateY}px)`,
|
||||||
@@ -161,6 +208,31 @@ export const Title: React.FC<TitleProps> = ({
|
|||||||
>
|
>
|
||||||
{title}
|
{title}
|
||||||
</h1>
|
</h1>
|
||||||
|
{secondaryTitle && (
|
||||||
|
<h2
|
||||||
|
style={{
|
||||||
|
transform: `translateY(${translateY}px)`,
|
||||||
|
textAlign: 'center',
|
||||||
|
color: stColor,
|
||||||
|
fontSize: `${stFontSize}px`,
|
||||||
|
fontWeight: stFontWeight,
|
||||||
|
fontFamily: stFontFile && stFontFile !== fontFile ? stFontFamilyCss : fontFamilyCss,
|
||||||
|
textShadow: buildTextShadow(stStrokeColor, stStrokeSize),
|
||||||
|
margin: 0,
|
||||||
|
marginTop: `${stMarginTop}px`,
|
||||||
|
width: '100%',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
padding: '0 5%',
|
||||||
|
lineHeight: 1.3,
|
||||||
|
whiteSpace: 'normal',
|
||||||
|
wordBreak: 'break-word',
|
||||||
|
overflowWrap: 'anywhere',
|
||||||
|
letterSpacing: `${stLetterSpacing}px`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{secondaryTitle}
|
||||||
|
</h2>
|
||||||
|
)}
|
||||||
</AbsoluteFill>
|
</AbsoluteFill>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# Qwen3-TTS 声音克隆服务启动脚本
|
# Qwen3-TTS voice clone startup script
|
||||||
# 端口: 8009
|
# Port: 8009
|
||||||
# GPU: 0
|
# GPU: 0
|
||||||
|
|
||||||
cd /home/rongye/ProgramFiles/ViGent2/models/Qwen3-TTS
|
cd /home/rongye/ProgramFiles/ViGent2/models/Qwen3-TTS
|
||||||
|
|
||||||
# 确保 conda env 的 bin 目录在 PATH 中,让 sox 等工具可被找到
|
# Ensure conda env bin is in PATH (for sox)
|
||||||
export PATH="/home/rongye/ProgramFiles/miniconda3/envs/qwen-tts/bin:$PATH"
|
export PATH="/home/rongye/ProgramFiles/miniconda3/envs/qwen-tts/bin:$PATH"
|
||||||
|
|
||||||
python qwen_tts_server.py
|
# Default model (can be overridden externally)
|
||||||
|
export QWEN_TTS_MODEL="${QWEN_TTS_MODEL:-1.7B-Base}"
|
||||||
|
|
||||||
|
exec python qwen_tts_server.py
|
||||||
|
|||||||
Reference in New Issue
Block a user