Compare commits

...

4 Commits

Author SHA1 Message Date
Kevin Wong
1717635bfd 更新 2026-02-25 17:51:58 +08:00
Kevin Wong
0a5a17402c 更新 2026-02-24 16:55:29 +08:00
Kevin Wong
bc0fe9326a 更新 2026-02-11 17:48:38 +08:00
Kevin Wong
035ee29d72 更新 2026-02-11 14:33:05 +08:00
72 changed files with 3192 additions and 942 deletions

1
.gitignore vendored
View File

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

@@ -0,0 +1,278 @@
# 支付宝付费开通会员 — 部署指南
本文档涵盖支付宝电脑网站支付功能的完整部署流程。用户注册后通过支付宝付费自动激活会员,有效期 1 年。
---
## 前置条件
- 支付宝企业/个体商户账号
- 已在 [支付宝开放平台](https://open.alipay.com) 创建应用并获取 APPID
- 应用已开通 **「电脑网站支付」** 产品权限(`alipay.trade.page.pay` 接口)
- 服务器域名已配置 HTTPS支付宝回调要求公网可达
---
## 第一部分:支付宝开放平台配置
### 1. 创建应用
登录 https://open.alipay.com → 控制台 → 创建应用(或使用已有应用)。
### 2. 开通「电脑网站支付」产品
进入应用详情 → 产品绑定/产品管理 → 添加 **「电脑网站支付」** → 提交审核。
> **注意**:未开通此产品会导致 `ACQ.ACCESS_FORBIDDEN` 错误。
### 3. 生成密钥对
进入应用详情 → 开发设置 → 接口加签方式 → 选择 **RSA2(SHA256)**
1. 使用支付宝官方密钥工具生成 RSA2048 密钥对
2.**应用公钥** 上传到开放平台
3. 上传后平台会显示 **支付宝公钥**`alipayPublicKey_RSA2`
最终你会得到两样东西:
- **应用私钥**:你本地保存,代码用来签名请求
- **支付宝公钥**:平台返回给你,代码用来验证回调签名
> 应用公钥只是上传用的中间产物,代码中不需要。
---
## 第二部分:服务器配置
### 1. 放置密钥文件
将密钥保存为标准 PEM 格式,放到 `backend/keys/` 目录:
```bash
mkdir -p /home/rongye/ProgramFiles/ViGent2/backend/keys
```
**`backend/keys/app_private_key.pem`**(应用私钥):
```
-----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASC...(你的私钥内容)
...
-----END PRIVATE KEY-----
```
**`backend/keys/alipay_public_key.pem`**(支付宝公钥):
```
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A...(支付宝公钥内容)
...
-----END PUBLIC KEY-----
```
#### PEM 格式要求
支付宝密钥工具导出的是一行纯文本,需要转换为标准 PEM 格式:
- 必须有头尾标记(`-----BEGIN/END ...-----`
- 密钥内容每 64 字符换行
- 私钥头标记为 `-----BEGIN PRIVATE KEY-----`PKCS#8 格式)
- 公钥头标记为 `-----BEGIN PUBLIC KEY-----`
如果你拿到的是一行裸密钥,用以下命令转换:
```bash
# 私钥格式化(假设裸密钥在 raw_private.txt 中)
echo "-----BEGIN PRIVATE KEY-----" > app_private_key.pem
cat raw_private.txt | fold -w 64 >> app_private_key.pem
echo "-----END PRIVATE KEY-----" >> app_private_key.pem
# 公钥格式化
echo "-----BEGIN PUBLIC KEY-----" > alipay_public_key.pem
cat raw_public.txt | fold -w 64 >> alipay_public_key.pem
echo "-----END PUBLIC KEY-----" >> alipay_public_key.pem
```
> `backend/keys/` 目录已加入 `.gitignore`,不会被提交到仓库。
### 2. 配置环境变量
`backend/.env` 中添加:
```ini
# =============== 支付宝配置 ===============
ALIPAY_APP_ID=你的应用APPID
ALIPAY_PRIVATE_KEY_PATH=/home/rongye/ProgramFiles/ViGent2/backend/keys/app_private_key.pem
ALIPAY_PUBLIC_KEY_PATH=/home/rongye/ProgramFiles/ViGent2/backend/keys/alipay_public_key.pem
ALIPAY_NOTIFY_URL=https://vigent.hbyrkj.top/api/payment/notify
ALIPAY_RETURN_URL=https://vigent.hbyrkj.top/pay
```
| 变量 | 说明 |
|------|------|
| `ALIPAY_APP_ID` | 支付宝开放平台应用 APPID |
| `ALIPAY_PRIVATE_KEY_PATH` | 应用私钥 PEM 文件绝对路径 |
| `ALIPAY_PUBLIC_KEY_PATH` | 支付宝公钥 PEM 文件绝对路径 |
| `ALIPAY_NOTIFY_URL` | 异步回调地址(服务器间通信),必须公网 HTTPS 可达 |
| `ALIPAY_RETURN_URL` | 同步跳转地址(用户支付完成后浏览器跳转回的页面) |
`config.py` 中还有几个可调参数(已有默认值,一般不需要加到 .env
| 变量 | 默认值 | 说明 |
|------|--------|------|
| `ALIPAY_SANDBOX` | `false` | 是否使用沙箱环境 |
| `PAYMENT_AMOUNT` | `999.00` | 会员价格(元) |
| `PAYMENT_EXPIRE_DAYS` | `365` | 会员有效天数 |
### 3. 创建数据库表
通过 Docker 在本地 Supabase 中执行:
```bash
docker exec -i supabase-db psql -U postgres -c "
CREATE TABLE IF NOT EXISTS orders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
out_trade_no TEXT UNIQUE NOT NULL,
amount DECIMAL(10, 2) NOT NULL DEFAULT 999.00,
status TEXT DEFAULT 'pending' CHECK (status IN ('pending', 'paid', 'failed')),
trade_no TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
paid_at TIMESTAMP WITH TIME ZONE
);
CREATE INDEX IF NOT EXISTS idx_orders_user_id ON orders(user_id);
CREATE INDEX IF NOT EXISTS idx_orders_out_trade_no ON orders(out_trade_no);
"
```
### 4. 安装依赖
```bash
# 后端(在 venv 中)
cd /home/rongye/ProgramFiles/ViGent2/backend
venv/bin/pip install python-alipay-sdk
```
> 前端无额外依赖需要安装。
### 5. Nginx 配置
确保 Nginx 将 `/api/payment/notify` 代理到后端。如果现有配置已覆盖 `/api/` 前缀,则无需额外修改:
```nginx
location /api/ {
proxy_pass http://localhost:8006;
# ... 现有配置
}
```
### 6. 重启服务
```bash
# 构建前端
cd /home/rongye/ProgramFiles/ViGent2/frontend
npx next build
# 重启
pm2 restart vigent2-backend
pm2 restart vigent2-frontend
```
---
## 第三部分:正式上线
测试通过后,将 `backend/app/core/config.py` 中的测试金额改为正式价格:
```python
PAYMENT_AMOUNT: float = 999.00 # 正式价格
```
或在 `backend/.env` 中添加覆盖:
```ini
PAYMENT_AMOUNT=999.00
```
然后重启后端:
```bash
pm2 restart vigent2-backend
```
---
## 支付流程说明
```
用户注册 → 登录(密码正确但 is_active=false
→ 后端返回 403 + payment_token
→ 前端跳转 /pay 页面
→ POST /api/payment/create-order → 返回支付宝收银台 URL
→ 前端重定向到支付宝收银台页面(支持扫码、账号登录、余额等多种支付方式)
→ 用户完成支付
→ 支付宝异步回调 POST /api/payment/notify
→ 后端验签 → 更新订单 → 激活用户is_active=true, expires_at=+365天
→ 支付宝同步跳转回 /pay?out_trade_no=xxx
→ 前端轮询 GET /api/payment/status/{out_trade_no}
→ 轮询到 paid → 提示成功 → 跳转登录页
→ 用户重新登录 → 成功进入系统
```
**电脑网站支付 vs 当面付**:电脑网站支付(`alipay.trade.page.pay`)会跳转到支付宝官方收银台页面,用户可以选择扫码、支付宝账号登录、余额等多种方式支付,体验更好。当面付(`alipay.trade.precreate`)仅生成一个二维码,只能扫码支付。
会员到期续费同流程:登录时检测到过期 → 返回 PAYMENT_REQUIRED → 跳转 /pay。
管理员手动激活功能不受影响,两种方式并存。
---
## 涉及文件
| 文件 | 变更类型 | 说明 |
|------|---------|------|
| `backend/requirements.txt` | 修改 | 添加 `python-alipay-sdk` |
| `backend/database/schema.sql` | 修改 | 新增 `orders` 表 |
| `backend/app/core/config.py` | 修改 | 支付宝配置项 |
| `backend/app/core/security.py` | 修改 | payment_token 函数 |
| `backend/app/core/deps.py` | 修改 | is_active 安全兜底 |
| `backend/app/repositories/orders.py` | 新建 | orders 数据层 |
| `backend/app/modules/payment/__init__.py` | 新建 | 模块初始化 |
| `backend/app/modules/payment/schemas.py` | 新建 | 请求/响应模型 |
| `backend/app/modules/payment/service.py` | 新建 | 支付业务逻辑(电脑网站支付) |
| `backend/app/modules/payment/router.py` | 新建 | 3 个 API 端点 |
| `backend/app/modules/auth/router.py` | 修改 | 登录返回 PAYMENT_REQUIRED |
| `backend/app/main.py` | 修改 | 注册 payment_router |
| `backend/.env` | 修改 | 支付宝环境变量 |
| `backend/keys/` | 新建 | PEM 密钥文件 |
| `frontend/src/shared/lib/auth.ts` | 修改 | login() 处理 paymentToken |
| `frontend/src/shared/api/axios.ts` | 修改 | PUBLIC_PATHS 加 /pay |
| `frontend/src/app/login/page.tsx` | 修改 | paymentToken 跳转 |
| `frontend/src/app/register/page.tsx` | 修改 | 注册成功提示文案 |
| `frontend/src/app/pay/page.tsx` | 新建 | 付费页面(重定向到支付宝收银台) |
---
## 常见问题
### RSA key format is not supported
密钥文件缺少 PEM 头尾标记或未按 64 字符换行。参考「PEM 格式要求」重新格式化。
### ACQ.ACCESS_FORBIDDEN
应用未开通「电脑网站支付」产品。在支付宝开放平台 → 应用详情 → 产品管理中添加并开通。
### 支付宝回调不到
1. 检查 `ALIPAY_NOTIFY_URL` 是否公网 HTTPS 可达
2. 检查 Nginx 是否将 `/api/payment/notify` 代理到后端
3. 支付宝回调超时15s 未响应)会重试,共重试 8 次,持续 24 小时
### 支付完成后页面未跳转回来
检查 `ALIPAY_RETURN_URL` 配置是否正确,必须是前端 `/pay` 页面的完整 URL`https://vigent.hbyrkj.top/pay`)。支付宝会在用户支付完成后将浏览器重定向到此地址,并附带 `out_trade_no` 等参数。
### 前端显示"网络错误"而非具体错误
API 函数缺少 try/catch 捕获 axios 异常。已在 `auth.ts``register()``login()` 中修复。

View File

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

View File

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

View File

@@ -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` | 支付宝支付集成 |
### 前端关键依赖 ### 前端关键依赖

View File

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

@@ -0,0 +1,254 @@
## 🔧 文案提取助手修复 — 抖音链接无法提取文案 (Day 25)
### 概述
文案提取助手粘贴抖音链接后无法提取文案yt-dlp 报错 `Fresh cookies are needed`,手动回退方案也因抖音页面结构变化失效。本日完成了完整修复,并清理了不再需要的 `DOUYIN_COOKIE` 配置。
---
## 🐛 问题诊断
### 错误链路
1. **yt-dlp 失败**`ERROR: [Douyin] Fresh cookies (not necessarily logged in) are needed`
- yt-dlp 版本 `2025.12.08` 过旧
- 抖音 API `aweme/v1/web/aweme/detail/` 需要签名 cookie`s_v_web_id` 等),即使升级 yt-dlp 到最新版 + 传入 cookie 仍无法解决,属 yt-dlp 已知问题
2. **手动回退失败**`Could not find RENDER_DATA in page`
- 旧方案通过桌面端用户主页 + `modal_id` 访问,抖音 SSR 已不再返回 `videoDetail` 数据
3. **`.env``DOUYIN_COOKIE`**:时间戳 2024 年 12 月,早已过期
---
## ✅ 修复方案:移动端分享页 + 自动获取 ttwid
### 核心思路
放弃依赖 yt-dlp 下载抖音视频和手动维护 cookie改为
1. 自动从 ByteDance 公共 API 获取新鲜 `ttwid`(匿名令牌,不绑定账号)
2.`ttwid` 访问移动端分享页 `m.douyin.com/share/video/{id}`
3. 从页面内嵌 JSON 中提取 `play_addr` 播放地址并下载
### 关键代码(`_download_douyin_manual` 重写)
```python
# 1. 获取新鲜 ttwid
ttwid_resp = await client.post(
"https://ttwid.bytedance.com/ttwid/union/register/",
json={"region": "cn", "aid": 6383, "service": "www.douyin.com", ...}
)
ttwid = ttwid_resp.cookies.get("ttwid", "")
# 2. 访问移动端分享页
page_resp = await client.get(
f"https://m.douyin.com/share/video/{video_id}",
headers={"cookie": f"ttwid={ttwid}", ...}
)
# 3. 提取 play_addr
addr_match = re.search(r'"play_addr":\{"uri":"([^"]+)","url_list":\["([^"]+)"', page_text)
video_url = addr_match.group(2).replace(r"\u002F", "/")
```
### 优势
- 不再依赖手动维护的 `DOUYIN_COOKIE`ttwid 每次请求自动获取
- 不受 yt-dlp 对抖音支持状况影响
- 所有用户通用,不绑定特定账号
---
## 🧹 清理 DOUYIN_COOKIE 配置
`DOUYIN_COOKIE` 仅用于文案提取,新方案不再需要,已从以下位置删除:
| 文件 | 变更 |
|------|------|
| `backend/.env` | 删除 `DOUYIN_COOKIE` 配置项及注释 |
| `backend/app/core/config.py` | 删除 `DOUYIN_COOKIE: str = ""` 字段定义 |
| `backend/app/modules/tools/service.py` | 删除 yt-dlp 传 cookie 逻辑和 `_write_netscape_cookies` 辅助函数 |
---
## 🔤 前端文案修正
将文案提取界面中的"AI 洗稿结果"改为"AI 改写结果"。
| 文件 | 变更 |
|------|------|
| `frontend/src/features/home/ui/ScriptExtractionModal.tsx` | `AI 洗稿结果``AI 改写结果` |
| `backend/app/modules/tools/service.py` | 注释中"洗稿"→"改写" |
| `backend/app/services/glm_service.py` | docstring 中"洗稿"→"改写文案" |
---
## 📦 其他变更
- **yt-dlp 升级**`2025.12.08``2026.2.21`
- **yt-dlp 初始化修正**:改为 `YoutubeDL(ydl_opts)` 直接传参初始化(原先空初始化后 update params 不生效)
- **User-Agent 更新**yt-dlp 中 `Chrome/91``Chrome/131`
---
## 涉及文件汇总
### 后端修改
| 文件 | 变更 |
|------|------|
| `backend/app/modules/tools/service.py` | 重写 `_download_douyin_manual`(移动端分享页方案);修正 yt-dlp 初始化;清理 cookie 相关代码;注释改写 |
| `backend/app/services/glm_service.py` | docstring "洗稿" → "改写文案" |
| `backend/app/core/config.py` | 删除 `DOUYIN_COOKIE` 字段 |
| `backend/.env` | 删除 `DOUYIN_COOKIE` 配置 |
| `backend/requirements.txt` | yt-dlp 版本升级 |
### 前端修改
| 文件 | 变更 |
|------|------|
| `frontend/src/features/home/ui/ScriptExtractionModal.tsx` | "AI 洗稿结果" → "AI 改写结果" |
---
## ✏️ AI 智能改写 — 自定义提示词功能
### 概述
文案提取助手的"AI 智能改写"原先使用硬编码 prompt用户无法定制改写风格。本次在 checkbox 右侧新增"自定义提示词"折叠区域,用户可编辑自定义 prompt持久化到 localStorage后端按需替换默认 prompt。
### 后端修改
**路由层** (`router.py`)`extract_script_tool` 新增可选 Form 参数 `custom_prompt: Optional[str] = Form(None)`,透传给 service。
**服务层** (`service.py`)`extract_script()` 签名新增 `custom_prompt`,透传给 `glm_service.rewrite_script(script, custom_prompt)`
**AI 层** (`glm_service.py`)`rewrite_script(self, text, custom_prompt=None)`,若 `custom_prompt` 有值则用自定义 prompt + 原文拼接,否则保持原有默认 prompt。
```python
if custom_prompt and custom_prompt.strip():
prompt = f"""{custom_prompt.strip()}
原始文案:
{text}"""
else:
prompt = f"""请将以下视频文案进行改写。...(原有默认)"""
```
### 前端修改
**Hook** (`useScriptExtraction.ts`)
- 新增 `customPrompt` / `showCustomPrompt` 状态
- 初始值从 `localStorage.getItem("vigent_rewriteCustomPrompt")` 恢复
- `customPrompt` 变化时防抖 300ms 保存到 localStorage
- `handleExtract()` 中若 `doRewrite && customPrompt.trim()` 有值,追加 `formData.append("custom_prompt", ...)`
- modal 重置时不清空 customPrompt持久化偏好
**UI** (`ScriptExtractionModal.tsx`)
- checkbox 同行右侧新增"自定义提示词 ▼"按钮(仅 `doRewrite` 时显示)
- 点击展开 textarea 编辑区域,底部提示"留空则使用默认提示词"
- 取消勾选 AI 智能改写时,自定义提示词区域自动隐藏
### 涉及文件
| 文件 | 变更 |
|------|------|
| `backend/app/modules/tools/router.py` | 新增 `custom_prompt` Form 参数 |
| `backend/app/modules/tools/service.py` | `extract_script()` 透传 `custom_prompt` |
| `backend/app/services/glm_service.py` | `rewrite_script()` 支持自定义 prompt |
| `frontend/.../useScriptExtraction.ts` | 新增状态、localStorage 持久化、FormData 传参 |
| `frontend/.../ScriptExtractionModal.tsx` | UI 按钮 + 展开 textarea |
### 验证
- 后端 `python -m py_compile` 三个文件通过
- 前端 `npx tsc --noEmit` 通过
---
## 🐛 SSR 构建修复 — localStorage is not defined
### 问题
`npm run build` 报错 `ReferenceError: localStorage is not defined`,因为 `useScriptExtraction.ts``useState` 的初始化函数在 SSRNode.js环境下也会执行而服务端没有 `localStorage`
### 修复
`useState` 初始化加 `typeof window !== "undefined"` 守卫:
```typescript
const [customPrompt, setCustomPrompt] = useState(
() => typeof window !== "undefined" ? localStorage.getItem(CUSTOM_PROMPT_KEY) || "" : ""
);
```
| 文件 | 变更 |
|------|------|
| `frontend/.../useScriptExtraction.ts` | `useState` 初始化增加 SSR 安全守卫 |
---
## 🎬 片头副标题功能
### 概述
新增片头副标题secondary_title显示在主标题下方用于补充说明或悬念引导。副标题有独立的样式配置字体、字号、颜色等可由 AI 同时生成20 字限制,仅在视频画面中显示,不参与发布标题。
命名约定:后端 `secondary_title`snake_case前端 `videoSecondaryTitle`camelCase用户界面"片头副标题"。
---
### 后端修改
| 文件 | 变更 |
|------|------|
| `backend/app/modules/videos/schemas.py` | `GenerateRequest` 新增 4 个可选字段:`secondary_title``secondary_title_style_id``secondary_title_font_size``secondary_title_top_margin` |
| `backend/app/services/glm_service.py` | AI prompt 增加副标题生成要求不超过20字JSON 格式新增 `secondary_title` 字段 |
| `backend/app/modules/ai/router.py` | `GenerateMetaResponse` 增加 `secondary_title: str = ""`endpoint 返回时取 `result.get("secondary_title", "")` |
| `backend/app/modules/videos/workflow.py` | `use_remotion` 条件增加 `or req.secondary_title`;副标题样式解析复用 `get_style("title", ...)`;字号/间距覆盖;`prepare_style_for_remotion` 处理副标题字体;`remotion_service.render()` 传入 `secondary_title` + `secondary_title_style` |
| `backend/app/services/remotion_service.py` | `render()` 新增 `secondary_title``secondary_title_style` 参数,构建 CLI 参数 `--secondaryTitle``--secondaryTitleStyle` |
### Remotion 修改
| 文件 | 变更 |
|------|------|
| `remotion/render.ts` | `RenderOptions` 新增 `secondaryTitle?` + `secondaryTitleStyle?``parseArgs()` 新增 switch case`inputProps` 新增两个字段 |
| `remotion/src/components/Title.tsx` | `TitleProps` 新增 `secondaryTitle?``secondaryTitleStyle?``AbsoluteFill` 改为 `flexDirection: 'column'` 垂直堆叠;主标题 `<h1>` 后增加副标题 `<h2>`,独立样式(默认字号 48px、字重 700共享淡入淡出动画副标题字体使用独立 `@font-face``SecondaryTitleFont`)避免与主标题冲突 |
| `remotion/src/Video.tsx` | `VideoProps` 新增 `secondaryTitle?` + `secondaryTitleStyle?`;传递给 `<Title>` 组件;渲染条件改为 `{(title \|\| secondaryTitle) && ...}` |
| `remotion/src/Root.tsx` | `defaultProps` 新增 `secondaryTitle: undefined` + `secondaryTitleStyle: undefined` |
### 前端修改
| 文件 | 变更 |
|------|------|
| `frontend/src/shared/lib/title.ts` | 新增 `SECONDARY_TITLE_MAX_LENGTH = 20``clampSecondaryTitle()` |
| `frontend/src/features/home/model/useHomeController.ts` | 新增状态 `videoSecondaryTitle``selectedSecondaryTitleStyleId``secondaryTitleFontSize``secondaryTitleTopMargin``secondaryTitleSizeLocked`;新建 `secondaryTitleInput = useTitleInput({ maxLength: 20 })`(不 sync 到发布页);`handleGenerateMeta()` 接收并填充 `secondary_title``handleGenerate()` 构建 payload 增加副标题字段return 暴露所有新状态 |
| `frontend/src/features/home/model/useHomePersistence.ts` | 新增 localStorage key`secondaryTitle``secondaryTitleStyle``secondaryTitleFontSize``secondaryTitleTopMargin`;对应的恢复和保存 effect |
| `frontend/src/features/home/ui/TitleSubtitlePanel.tsx` | Props 新增副标题相关;主标题输入框下方添加"片头副标题限制20个字"输入框;副标题样式选择器(复用 titleStyles 预设、字号滑块30-100px、间距滑块0-100px |
| `frontend/src/features/home/ui/FloatingStylePreview.tsx` | 标题预览改为 flex column 布局;主标题下方增加副标题预览行,独立样式渲染 |
| `frontend/src/features/home/ui/HomePage.tsx` | 从 `useHomeController` 解构新状态,传给 `TitleSubtitlePanel` |
---
## 🐛 参考音频上传 — 中文文件名 InvalidKey 修复
### 问题
上传中文名参考音频(如"我的声音.wav"Supabase Storage 报 `InvalidKey`,因为存储路径直接使用了原始中文文件名。
### 修复
`ref_audios/service.py` 新增 `sanitize_filename()` 函数,将存储路径的文件名清洗为 ASCII 安全字符(仅 `A-Za-z0-9._-`
- NFKD 规范化 → 丢弃非 ASCII → 非法字符替换为 `_`
- 纯中文/emoji 清洗后为空时,使用 MD5 哈希兜底(如 `audio_e924b1193007`
- 文件名限长 50 字符
- 原始中文文件名保留在 metadata 中作为展示名,前端显示不受影响
```
修复前: cbbe.../1771915755_我的声音.wav → InvalidKey
修复后: cbbe.../1771915755_audio_xxxxxxxx.wav → 上传成功
```
| 文件 | 变更 |
|------|------|
| `backend/app/modules/ref_audios/service.py` | 新增 `sanitize_filename()` 函数,上传路径使用清洗后文件名 |

239
Docs/DevLogs/Day26.md Normal file
View File

@@ -0,0 +1,239 @@
## 🎨 前端优化:板块合并 + 序号标题 + UI 精细化 (Day 26)
### 概述
首页原有 9 个独立板块(左栏 7 个 + 右栏 2 个),每个都有自己的卡片容器和标题,视觉碎片化严重。本次将相关板块合并为 5 个主板块,添加中文序号(一~十),移除 emoji 图标,并对多个子组件的布局和交互细节进行优化。
---
## ✅ 改动内容
### 1. 板块合并方案
**左栏4 个主板块 + 2 个独立区域):**
| 序号 | 板块名 | 子板块 | 原组件 |
|------|--------|--------|--------|
| 一 | 文案提取与编辑 | — | ScriptEditor |
| 二 | 标题与字幕 | — | TitleSubtitlePanel |
| 三 | 配音 | 配音方式 / 配音列表 | VoiceSelector + GeneratedAudiosPanel |
| 四 | 素材编辑 | 视频素材 / 时间轴编辑 | MaterialSelector + TimelineEditor |
| 五 | 背景音乐 | — | BgmPanel |
| — | 生成按钮 | — | GenerateActionBar不编号 |
**右栏1 个主板块):**
| 序号 | 板块名 | 子板块 | 原组件 |
|------|--------|--------|--------|
| 六 | 作品 | 作品列表 / 作品预览 | HistoryList + PreviewPanel |
**发布页(/publish**
| 序号 | 板块名 |
|------|--------|
| 七 | 平台账号 |
| 八 | 选择发布作品 |
| 九 | 发布信息 |
| 十 | 选择发布平台 |
### 2. embedded 模式
6 个组件新增 `embedded?: boolean` prop默认 `false`
- `VoiceSelector` — embedded 时不渲染外层卡片和主标题
- `GeneratedAudiosPanel` — embedded 时两行布局:第 1 行(语速+生成配音右对齐)、第 2 行(配音列表+刷新)
- `MaterialSelector` — embedded 时自渲染 h3 子标题"视频素材"+ 上传/刷新按钮同行
- `TimelineEditor` — embedded 时自渲染 h3 子标题"时间轴编辑"+ 画面比例/播放控件同行
- `PreviewPanel` — embedded 时不渲染外层卡片和标题
- `HistoryList` — embedded 时不渲染外层卡片和标题(刷新按钮由 HomePage 提供)
### 3. 序号标题 + emoji 移除
所有编号板块移除 emoji 图标,使用纯中文序号:
- ScriptEditor: `✍️ 文案提取与编辑``一、文案提取与编辑`
- TitleSubtitlePanel: `🎬 标题与字幕``二、标题与字幕`
- BgmPanel: `🎵 背景音乐``五、背景音乐`
- HomePage 右栏: `五、作品``六、作品`
- PublishPage: `👤 平台账号``七、平台账号``📹 选择发布作品``八、选择发布作品``✍️ 发布信息``九、发布信息``📱 选择发布平台``十、选择发布平台`
### 4. 子标题与分隔样式
- **主标题**: `text-base sm:text-lg font-semibold text-white`
- **子标题**: `text-sm font-medium text-gray-400`
- **分隔线**: `<div className="border-t border-white/10 my-4" />`
### 5. 配音列表布局优化
GeneratedAudiosPanel embedded 模式下采用两行布局:
- **第 1 行**:语速下拉 + 生成配音按钮(右对齐,`flex justify-end`
- **第 2 行**`<h3>配音列表</h3>` + 刷新按钮(两端对齐)
- 非 embedded 模式保持原单行布局
### 6. TitleSubtitlePanel 下拉对齐
- 标题样式/副标题样式/字幕样式三行标签统一 `w-20`(固定 80px确保下拉菜单垂直对齐
- 下拉菜单宽度 `w-1/3 min-w-[100px]`,避免过宽
### 7. RefAudioPanel 文案简化
- 原底部段落"上传任意语音样本3-10秒…" 移至 "我的参考音频" 标题旁,简化为 `(上传3-10秒语音样本)`
### 8. 账户下拉菜单添加手机号
- AccountSettingsDropdown 在账户有效期上方新增手机号显示区域
- 显示 `user?.phone || '未知账户'`
### 9. 标题显示模式对副标题生效
- **payload 修复**: `useHomeController.ts``title_display_mode` 的发送条件从 `videoTitle.trim()` 改为 `videoTitle.trim() || videoSecondaryTitle.trim()`,确保仅有副标题时也能发送显示模式
- **UI 调整**: 短暂显示/常驻显示下拉从片头标题输入行移至"二、标题与字幕"板块标题行(与预览样式按钮同行),明确表示该设置对标题和副标题同时生效
- Remotion 端 `Title.tsx` 已支持(标题和副标题作为整体组件渲染,`displayMode` 统一控制)
### 10. 时间轴模糊遮罩
遮罩从外层 wrapper 移入"四、素材编辑"卡片内,仅覆盖时间轴子区域(`rounded-xl`)。
### 11. 登录后用户信息立即可用
- AuthContext 新增 `setUser` 方法暴露给消费者
- 登录页成功后调用 `setUser(result.user)` 立即写入 Context无需等页面刷新
- 修复登录后账户下拉显示"未知账户"、刷新后才显示手机号的问题
### 12. 文案与选项微调
- MaterialSelector 描述 `(可多选最多4个)``(上传自拍视频最多可选4个)`
- TitleSubtitlePanel 显示模式选项 `短暂显示/常驻显示``标题短暂显示/标题常驻显示`
### 13. UI/UX 体验优化6 项)
- **操作按钮移动端可见**: 配音列表、作品列表、素材列表、参考音频、历史文案的操作按钮从 `opacity-0`hover 才显示)改为 `opacity-40`平时半透明可见hover 全亮),解决触屏设备无法发现按钮的问题
- **手机号脱敏**: AccountSettingsDropdown 手机号中间四位遮掩 `138****5678`
- **标题字数计数器**: TitleSubtitlePanel 标题/副标题输入框右侧显示实时字数 `3/15`,超限变红
- **列表滚动条提示**: ~~配音列表、作品列表、素材列表、BGM 列表从 `hide-scrollbar` 改为 `custom-scrollbar`~~ → 已全部改回 `hide-scrollbar` 隐藏滚动条(滚动功能不变)
- **时间轴拖拽提示**: TimelineEditor 色块左上角新增 `GripVertical` 抓手图标,暗示可拖拽排序
- **截取滑块放大**: ClipTrimmer 手柄从 16px 放大到 20px触控区从 32px 放大到 40px
### 14. 代码质量修复4 项)
- **AccountSettingsDropdown**: 关闭密码弹窗补齐 `setSuccess('')` 清空
- **MaterialSelector**: `selectedSet``useMemo` 避免每次渲染重建
- **TimelineEditor**: `visibleSegments`/`overflowSegments``useMemo`
- **MaterialSelector**: 素材满 4 个时非选中项按钮加 `disabled`
### 15. 发布页平台账号响应式布局
- **单行布局**:图标+名称+状态在左,按钮在右(`flex items-center`
- **移动端紧凑**:图标 `h-6 w-6`、按钮 `text-xs px-2 py-1 rounded-md`、间距 `space-y-2 px-3 py-2.5`
- **桌面端宽松**`sm:h-7 sm:w-7``sm:text-sm sm:px-3 sm:py-1.5 sm:rounded-lg``sm:space-y-3 sm:px-4 sm:py-3.5`
- 两端各自美观,风格与其他板块一致
### 16. 移动端刷新回顶部修复
- **问题**: 移动端刷新页面后不回到顶部,而是滚动到背景音乐板块
- **根因**: 1) 浏览器原生滚动恢复覆盖 `scrollTo(0,0)`2) 列表 scroll effect 有双依赖(`selectedId` + `list`),数据异步加载时第二次触发跳过了 ref 守卫,执行了 `scrollIntoView` 导致页面跳动
- **修复**: 三管齐下 — ① `history.scrollRestoration = "manual"` 禁用浏览器原生恢复;② 时间门控 `scrollEffectsEnabled` ref1 秒内禁止所有列表自动滚动)替代单次 ref 守卫;③ 200ms 延迟兜底 `scrollTo(0,0)`
### 17. 移动端样式预览窗口缩小
- **问题**: 移动端点击"预览样式"后窗口占满整屏(宽 358px高约 636px遮挡样式调节控件
- **修复**: 移动端宽度从 `window.innerWidth - 32` 缩小到 **160px**;位置从左上角改为**右下角**`right:12, bottom:12`),不遮挡上方控件;最大高度限制 `50dvh`
- 桌面端保持不变280px左上角
### 18. 列表滚动条统一隐藏
- 将 Day 26 早期改为 `custom-scrollbar`(细紫色滚动条)的 7 处全部改回 `hide-scrollbar`
- 涉及BgmPanel、GeneratedAudiosPanel、HistoryList、MaterialSelector2处、ScriptExtractionModal2处
- 滚动功能不受影响,仅视觉上不显示滚动条
### 19. 配音按钮移动端适配
- VoiceSelector "选择声音/克隆声音" 按钮:内边距 `px-4``px-2 sm:px-4`,字号加 `text-sm sm:text-base`,图标加 `shrink-0`
- 修复移动端窄屏下按钮被挤压导致"克隆声音"不可见的问题
### 20. 素材标题溢出修复
- MaterialSelector embedded 标题行移除 `whitespace-nowrap`
- 描述文字 `(上传自拍视频最多可选4个)` 在移动端隐藏(`hidden sm:inline`),桌面端正常显示
- 修复移动端刷新按钮被推出容器外的问题
### 21. 生成配音按钮放大
- "生成配音" 作为核心操作按钮,从辅助尺寸升级为主操作尺寸
- 内边距 `px-2/px-3 py-1/py-1.5``px-4 py-2`,字号 `text-xs``text-sm font-medium`
- 图标 `h-3.5 w-3.5``h-4 w-4`,新增 `shadow-sm` + hover `shadow-md`
- embedded 与非 embedded 模式统一放大
### 22. 生成进度条位置调整
- **问题**: 生成进度条在"六、作品"卡片内部(作品预览下方),不够醒目
- **修复**: 进度条从 PreviewPanel 内部提取到 HomePage 右栏,作为独立卡片渲染在"六、作品"卡片**上方**
- 使用紫色边框(`border-purple-500/30`)区分,显示任务消息和百分比
- PreviewPanel embedded 模式下不再渲染进度条(传入 `currentTask={null}`
- 生成完成后进度卡片自动消失
### 23. LatentSync 超时修复
- **问题**: 约 2 分钟的视频3023 帧190 段推理)预计推理 54 分钟,但 httpx 超时仅 20 分钟,导致 LatentSync 调用失败并回退到无口型同步
- **根因**: `lipsync_service.py``httpx.AsyncClient(timeout=1200.0)` 不足以覆盖长视频推理时间
- **修复**: 超时从 `1200s`20 分钟)改为 `3600s`1 小时),足以覆盖 2-3 分钟视频的推理
### 24. 字幕时间戳节奏映射(修复长视频字幕漂移)
- **问题**: 2 分钟视频字幕明显对不上语音,越到后面偏差越大
- **根因**: `whisper_service.py``original_text` 处理逻辑丢弃了 Whisper 逐词时间戳,仅保留总时间范围后做全程线性插值,每个字分配相同时长,完全忽略语速变化和停顿
- **修复**: 保留 Whisper 的逐字时间戳作为语音节奏模板,将原文字符按比例映射到 Whisper 时间节奏上rhythm-mapping而非线性均分。字幕文字不变只是时间戳跟随真实语速
- **算法**: 原文第 i 个字符映射到 Whisper 时间线的 `(i/N)*M` 位置N=原文字符数M=Whisper字符数在相邻 Whisper 时间点间线性插值
---
## 📁 修改文件清单
| 文件 | 改动 |
|------|------|
| `VoiceSelector.tsx` | 新增 embedded prop移动端按钮适配`px-2 sm:px-4` |
| `GeneratedAudiosPanel.tsx` | 新增 embedded prop两行布局操作按钮可见度"生成配音"按钮放大 |
| `MaterialSelector.tsx` | 新增 embedded prop自渲染子标题+操作按钮useMemodisabled 守卫,操作按钮可见度,标题溢出修复 |
| `TimelineEditor.tsx` | 新增 embedded prop自渲染子标题+控件useMemo拖拽抓手图标 |
| `PreviewPanel.tsx` | 新增 embedded prop |
| `HistoryList.tsx` | 新增 embedded prop操作按钮可见度 |
| `ScriptEditor.tsx` | 标题加序号,移除 emoji操作按钮可见度 |
| `TitleSubtitlePanel.tsx` | 标题加序号,移除 emoji下拉对齐显示模式下拉上移字数计数器 |
| `BgmPanel.tsx` | 标题加序号 |
| `HomePage.tsx` | 核心重构:合并板块、序号标题、生成配音按钮迁入、`scrollRestoration` + 延迟兜底修复刷新回顶部、生成进度条提取到作品卡片上方 |
| `PublishPage.tsx` | 四个板块加序号(七~十),移除 emoji平台卡片响应式单行布局 |
| `RefAudioPanel.tsx` | 简化提示文案,操作按钮可见度 |
| `AccountSettingsDropdown.tsx` | 新增手机号显示(脱敏),补齐 success 清空 |
| `AuthContext.tsx` | 新增 `setUser` 方法,登录后立即更新用户状态 |
| `login/page.tsx` | 登录成功后调用 `setUser` 写入用户数据 |
| `useHomeController.ts` | titleDisplayMode 条件修复,列表 scroll 时间门控 `scrollEffectsEnabled` |
| `FloatingStylePreview.tsx` | 移动端预览窗口缩小160px并移至右下角 |
| `ScriptExtractionModal.tsx` | 滚动条改回隐藏 |
| `ClipTrimmer.tsx` | 滑块手柄放大、触控区增高 |
| `lipsync_service.py` | httpx 超时从 1200s 改为 3600s |
| `whisper_service.py` | 字幕时间戳从线性插值改为 Whisper 节奏映射 |
---
## 🔍 验证
- `npm run build` — 零报错零警告
- 合并后布局:各子板块分隔清晰、主标题有序号
- 向后兼容:`embedded` 默认 `false`,组件独立使用不受影响
- 配音列表两行布局:语速+生成配音在上,配音列表+刷新在下
- 下拉菜单垂直对齐正确
- 短暂显示/常驻显示对标题和副标题同时生效
- 操作按钮在移动端(触屏)可见
- 手机号脱敏显示
- 标题字数计数器正常
- 列表滚动条全部隐藏
- 时间轴拖拽抓手图标显示
- 发布页平台卡片:移动端紧凑、桌面端宽松,风格一致
- 移动端刷新后回到顶部,不再滚动到背景音乐位置
- 移动端样式预览窗口不遮挡控件
- 移动端配音按钮(选择声音/克隆声音)均可见
- 移动端素材标题行按钮不溢出
- 生成配音按钮视觉层级高于辅助按钮
- 生成进度条在作品卡片上方独立显示
- LatentSync 长视频推理不再超时回退
- 字幕时间戳与语音节奏同步,长视频不漂移

View File

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

View File

@@ -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);
}, []);
// 列表滚动 effectBGM/素材/视频等)
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` 统一处理输入逻辑。

View File

@@ -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不遮挡控件
- **输入辅助**: 标题/副标题输入框实时字数计数器,超限变红

View File

@@ -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 节奏映射,修复长视频字幕漂移 |

View File

@@ -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/CFRconcat 增加 `+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` ref1 秒内禁止自动滚动)+ 延迟兜底 `scrollTo(0,0)`
- [x] **移动端样式预览缩小**: FloatingStylePreview 移动端宽度缩至 160px位置改为右下角不遮挡样式调节控件。
- [x] **列表滚动条统一隐藏**: 所有列表BGM/配音/作品/素材/文案提取)滚动条改回 `hide-scrollbar`
- [x] **移动端配音/素材适配**: VoiceSelector 按钮移动端缩小(`px-2 sm:px-4`修复克隆声音不可见MaterialSelector 标题行移除 `whitespace-nowrap`,描述移动端隐藏,修复刷新按钮溢出。
- [x] **生成配音按钮放大**: 从辅助尺寸(`text-xs px-2 py-1`)升级为主操作尺寸(`text-sm font-medium px-4 py-2`),新增阴影。
- [x] **生成进度条位置调整**: 从"六、作品"卡片内部提取到右栏独立卡片,显示在作品卡片上方,更醒目。
- [x] **LatentSync 超时修复**: httpx 超时从 1200s20 分钟)改为 3600s1 小时),修复 2 分钟以上视频口型推理超时回退问题。
- [x] **字幕时间戳节奏映射**: `whisper_service.py` 从全程线性插值改为 Whisper 逐词节奏映射,修复长视频字幕漂移。
### Day 25: 文案提取修复 + 自定义提示词 + 片头副标题
- [x] **抖音文案提取修复**: yt-dlp Fresh cookies 报错,重写 `_download_douyin_manual` 为移动端分享页 + 自动获取 ttwid 方案。
- [x] **清理 DOUYIN_COOKIE**: 新方案不再需要手动维护 Cookie`.env`/`config.py`/`service.py` 全面删除。
- [x] **AI 智能改写自定义提示词**: 后端 `rewrite_script()` 支持 `custom_prompt` 参数;前端 checkbox 旁新增折叠式提示词编辑区localStorage 持久化。
- [x] **SSR 构建修复**: `useState` 初始化 `localStorage` 访问加 `typeof window` 守卫,修复 `npm run build` 报错。
- [x] **片头副标题**: 新增 secondary_title后端/Remotion/前端全链路AI 同时生成独立样式配置20 字限制。
- [x] **前端文案修正**: "AI 洗稿结果"→"AI 改写结果"。
- [x] **yt-dlp 升级**: `2025.12.08``2026.2.21`
- [x] **参考音频中文文件名修复**: `sanitize_filename()` 将存储路径清洗为 ASCII 安全字符,纯中文名哈希兜底,原始名保留为展示名。
### Day 24: 鉴权到期治理 + 多素材时间轴稳定性修复
- [x] **会员到期请求时失效**: 登录与鉴权接口统一执行 `expires_at` 检查;到期后自动停用账号、清理 session并返回“会员已到期请续费”。
- [x] **画面比例控制**: 时间轴新增 `9:16 / 16:9` 输出比例选择,前端持久化并透传后端,单素材/多素材统一按目标分辨率处理。
- [x] **标题/字幕防溢出**: Remotion 与前端预览统一响应式缩放、自动换行、描边/字距/边距比例缩放,降低预览与成片差异。
- [x] **标题显示模式**: 标题行新增“短暂显示/常驻显示”下拉默认短暂显示4 秒),用户选择持久化并透传至 Remotion 渲染链路。
- [x] **MOV 方向归一化**: 新增旋转元数据解析与 orientation normalize修复“编码横屏+旋转元数据”导致的竖屏判断偏差。
- [x] **多素材拼接稳定性**: 片段 prepare 与 concat 统一 25fps/CFRconcat 增加 `+genpts`,缓解段切换处“画面冻结口型还动”。
- [x] **时间轴语义对齐**: 打通 `source_end` 全链路;修复 `sourceStart>0 且 sourceEnd=0` 时长计算;生成时以时间轴可见段 assignments 为准,超出段不参与。
- [x] **交互细节优化**: 页面刷新回顶部;素材/历史列表首轮自动滚动抑制,减少恢复状态时页面跳动。
### Day 23: 配音前置重构 + 素材时间轴编排 + UI 体验优化 + 声音克隆增强
#### 第一阶段:配音前置 #### 第一阶段:配音前置
- [x] **配音生成独立化**: 新增 `generated_audios` 后端模块router/schemas/service5 个 API 端点,复用现有 TTSService / voice_clone_service / task_store。 - [x] **配音生成独立化**: 新增 `generated_audios` 后端模块router/schemas/service5 个 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 |
--- ---

View File

@@ -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) - 接口规范与开发流程。

View File

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

View File

@@ -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 目录路径 (动态计算)"""

View File

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

View File

@@ -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 token30 分钟有效)"""
payload = {
"sub": user_id,
"purpose": "payment",
"exp": datetime.now(timezone.utc) + timedelta(minutes=30),
}
return jwt.encode(payload, settings.JWT_SECRET_KEY, algorithm=settings.JWT_ALGORITHM)
def decode_payment_token(token: str) -> str | None:
"""解析 payment_token返回 user_id仅 purpose=payment 有效)"""
try:
data = jwt.decode(
token,
settings.JWT_SECRET_KEY,
algorithms=[settings.JWT_ALGORITHM],
)
if data.get("purpose") != "payment":
return None
return data.get("sub")
except JWTError:
return None

View File

@@ -16,6 +16,7 @@ from app.modules.ai.router import router as ai_router
from app.modules.tools.router import router as tools_router from app.modules.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")

View File

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

View File

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

View File

View File

@@ -0,0 +1,52 @@
"""
支付 API创建订单、异步通知、状态查询
遵循 BACKEND_DEV.md 规范router 只做参数校验、调用 service、返回统一响应
"""
from fastapi import APIRouter, HTTPException, Request, status
from fastapi.responses import PlainTextResponse
from app.core.response import success_response
from .schemas import CreateOrderRequest, CreateOrderResponse, OrderStatusResponse
from . import service
router = APIRouter(prefix="/api/payment", tags=["支付"])
@router.post("/create-order")
async def create_payment_order(request: CreateOrderRequest):
"""创建支付宝电脑网站支付订单,返回收银台 URL"""
try:
result = service.create_payment_order(request.payment_token)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=str(e))
except RuntimeError as e:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e))
return success_response(
CreateOrderResponse(**result).model_dump()
)
@router.post("/notify")
async def payment_notify(request: Request):
"""
支付宝异步通知回调
必须返回纯文本 "success"(不是 JSON否则支付宝会重复推送。
"""
form_data = await request.form()
verified = service.handle_payment_notify(dict(form_data))
return PlainTextResponse("success" if verified else "fail")
@router.get("/status/{out_trade_no}")
async def check_payment_status(out_trade_no: str):
"""查询订单支付状态(前端轮询)"""
order_status = service.get_order_status(out_trade_no)
if order_status is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="订单不存在")
return success_response(
OrderStatusResponse(status=order_status).model_dump()
)

View File

@@ -0,0 +1,15 @@
from pydantic import BaseModel
class CreateOrderRequest(BaseModel):
payment_token: str
class CreateOrderResponse(BaseModel):
pay_url: str
out_trade_no: str
amount: float
class OrderStatusResponse(BaseModel):
status: str

View File

@@ -0,0 +1,137 @@
"""
支付业务服务
职责Alipay SDK 封装、创建订单、处理支付通知、查询状态
遵循 BACKEND_DEV.md "薄路由 + 厚服务" 原则
"""
from datetime import datetime, timezone, timedelta
import uuid
from alipay import AliPay
from loguru import logger
from app.core.config import settings
from app.core.security import decode_payment_token
from app.repositories.orders import create_order, get_order_by_trade_no, update_order_status
from app.repositories.users import update_user
# 支付宝网关地址
ALIPAY_GATEWAY = "https://openapi.alipay.com/gateway.do"
ALIPAY_GATEWAY_SANDBOX = "https://openapi-sandbox.dl.alipaydev.com/gateway.do"
def _get_alipay_client() -> AliPay:
"""延迟初始化 Alipay 客户端"""
return AliPay(
appid=settings.ALIPAY_APP_ID,
app_notify_url=settings.ALIPAY_NOTIFY_URL,
app_private_key_string=open(settings.ALIPAY_PRIVATE_KEY_PATH).read(),
alipay_public_key_string=open(settings.ALIPAY_PUBLIC_KEY_PATH).read(),
sign_type="RSA2",
debug=settings.ALIPAY_SANDBOX,
)
def _create_page_pay_url(out_trade_no: str, amount: float, subject: str) -> str | None:
"""调用 alipay.trade.page.pay返回支付宝收银台 URL"""
client = _get_alipay_client()
order_string = client.api_alipay_trade_page_pay(
subject=subject,
out_trade_no=out_trade_no,
total_amount=amount,
return_url=settings.ALIPAY_RETURN_URL,
)
if not order_string:
logger.error(f"电脑网站支付下单失败: {out_trade_no}")
return None
gateway = ALIPAY_GATEWAY_SANDBOX if settings.ALIPAY_SANDBOX else ALIPAY_GATEWAY
pay_url = f"{gateway}?{order_string}"
logger.info(f"电脑网站支付下单成功: {out_trade_no}")
return pay_url
def _verify_signature(data: dict, signature: str) -> bool:
"""验证支付宝异步通知签名"""
client = _get_alipay_client()
return client.verify(data, signature)
def create_payment_order(payment_token: str) -> dict:
"""
创建支付订单完整流程
Returns: {"pay_url": str, "out_trade_no": str, "amount": float}
Raises: ValueError (token 无效), RuntimeError (API 失败)
"""
user_id = decode_payment_token(payment_token)
if not user_id:
raise ValueError("付费凭证无效或已过期,请重新登录")
out_trade_no = f"VG_{int(datetime.now().timestamp())}_{uuid.uuid4().hex[:8]}"
amount = settings.PAYMENT_AMOUNT
create_order(user_id, out_trade_no, amount)
pay_url = _create_page_pay_url(out_trade_no, amount, "IPAgent 会员开通")
if not pay_url:
raise RuntimeError("创建支付订单失败,请稍后重试")
logger.info(f"用户 {user_id} 创建支付订单: {out_trade_no}")
return {"pay_url": pay_url, "out_trade_no": out_trade_no, "amount": amount}
def handle_payment_notify(form_data: dict) -> bool:
"""
处理支付宝异步通知完整流程
Returns: True=验签通过, False=验签失败
"""
data = dict(form_data)
signature = data.pop("sign", "")
data.pop("sign_type", None)
if not _verify_signature(data, signature):
logger.warning(f"支付宝通知验签失败: {data.get('out_trade_no')}")
return False
out_trade_no = data.get("out_trade_no", "")
trade_status = data.get("trade_status", "")
trade_no = data.get("trade_no", "")
logger.info(f"收到支付宝通知: {out_trade_no}, status={trade_status}, trade_no={trade_no}")
if trade_status not in ("TRADE_SUCCESS", "TRADE_FINISHED"):
return True
order = get_order_by_trade_no(out_trade_no)
if not order:
logger.warning(f"订单不存在: {out_trade_no}")
return True
if order["status"] == "paid":
logger.info(f"订单已处理过: {out_trade_no}")
return True
update_order_status(out_trade_no, "paid", trade_no)
user_id = order["user_id"]
expires_at = (datetime.now(timezone.utc) + timedelta(days=settings.PAYMENT_EXPIRE_DAYS)).isoformat()
update_user(user_id, {
"is_active": True,
"role": "user",
"expires_at": expires_at,
})
logger.success(f"用户 {user_id} 支付成功,已激活,有效期至 {expires_at}")
return True
def get_order_status(out_trade_no: str) -> str | None:
"""查询订单支付状态"""
order = get_order_by_trade_no(out_trade_no)
if not order:
return None
return order["status"]

View File

@@ -2,9 +2,11 @@ import re
import os import 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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,34 @@
"""
订单数据访问层
"""
from datetime import datetime, timezone
from typing import Any, Dict, Optional, cast
from app.core.supabase import get_supabase
def create_order(user_id: str, out_trade_no: str, amount: float) -> Dict[str, Any]:
supabase = get_supabase()
result = supabase.table("orders").insert({
"user_id": user_id,
"out_trade_no": out_trade_no,
"amount": amount,
"status": "pending",
}).execute()
return cast(Dict[str, Any], (result.data or [{}])[0])
def get_order_by_trade_no(out_trade_no: str) -> Optional[Dict[str, Any]]:
supabase = get_supabase()
result = supabase.table("orders").select("*").eq("out_trade_no", out_trade_no).single().execute()
return cast(Optional[Dict[str, Any]], result.data or None)
def update_order_status(out_trade_no: str, status: str, trade_no: str | None = None) -> None:
supabase = get_supabase()
payload: Dict[str, Any] = {"status": status}
if trade_no:
payload["trade_no"] = trade_no
if status == "paid":
payload["paid_at"] = datetime.now(timezone.utc).isoformat()
supabase.table("orders").update(payload).eq("out_trade_no", out_trade_no).execute()

View File

@@ -35,18 +35,19 @@ class GLMService:
Returns: 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())

View File

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

View File

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

View File

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

View File

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

@@ -0,0 +1,31 @@
{
"name": "backend",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"dependencies": {
"qrcode.react": "^4.2.0"
}
},
"node_modules/qrcode.react": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz",
"integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==",
"license": "ISC",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/react": {
"version": "19.2.4",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
}
}
}

5
backend/package.json Normal file
View File

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

View File

@@ -29,6 +29,9 @@ python-jose[cryptography]>=3.3.0
passlib[bcrypt]>=1.7.4 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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,160 @@
'use client';
import { Suspense, useState, useEffect, useRef } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import api from '@/shared/api/axios';
type PageStatus = 'loading' | 'redirecting' | 'checking' | 'success' | 'error';
function PayContent() {
const router = useRouter();
const searchParams = useSearchParams();
const [status, setStatus] = useState<PageStatus>('loading');
const [errorMsg, setErrorMsg] = useState('');
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
useEffect(() => {
const outTradeNo = searchParams.get('out_trade_no');
if (outTradeNo) {
setStatus('checking');
startPolling(outTradeNo);
return;
}
const token = sessionStorage.getItem('payment_token');
if (!token) {
router.replace('/login');
return;
}
createOrder(token);
return () => {
if (pollRef.current) clearInterval(pollRef.current);
};
}, []);
const createOrder = async (token: string) => {
try {
const { data } = await api.post('/api/payment/create-order', { payment_token: token });
const { pay_url } = data.data;
setStatus('redirecting');
window.location.href = pay_url;
} catch (err: any) {
setStatus('error');
setErrorMsg(err.response?.data?.message || '创建订单失败,请重新登录');
}
};
const startPolling = (tradeNo: string) => {
checkStatus(tradeNo);
pollRef.current = setInterval(() => checkStatus(tradeNo), 3000);
};
const checkStatus = async (tradeNo: string) => {
try {
const { data } = await api.get(`/api/payment/status/${tradeNo}`);
if (data.data.status === 'paid') {
if (pollRef.current) clearInterval(pollRef.current);
setStatus('success');
sessionStorage.removeItem('payment_token');
setTimeout(() => router.replace('/login'), 3000);
}
} catch {
// ignore polling errors
}
};
return (
<div className="w-full max-w-md p-8 bg-white/10 backdrop-blur-lg rounded-2xl shadow-2xl border border-white/20">
{(status === 'loading' || status === 'redirecting') && (
<div className="text-center">
<div className="mb-6">
<svg className="animate-spin h-12 w-12 mx-auto text-purple-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
</div>
<p className="text-gray-300">
{status === 'loading' ? '正在创建订单...' : '正在跳转到支付宝...'}
</p>
</div>
)}
{status === 'checking' && (
<div className="text-center">
<h1 className="text-2xl font-bold text-white mb-6"></h1>
<div className="flex items-center justify-center gap-2 text-purple-300 mb-4">
<svg className="animate-spin h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
...
</div>
<p className="text-gray-400 text-sm"></p>
</div>
)}
{status === 'success' && (
<div className="text-center">
<div className="mb-6">
<svg className="w-16 h-16 mx-auto text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<h2 className="text-2xl font-bold text-white mb-4"></h2>
<p className="text-gray-300 mb-2">...</p>
<p className="text-gray-500 text-sm">使</p>
</div>
)}
{status === 'error' && (
<div className="text-center">
<div className="mb-6">
<svg className="w-16 h-16 mx-auto text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<h2 className="text-2xl font-bold text-white mb-4"></h2>
<p className="text-red-300 mb-6">{errorMsg}</p>
<button
onClick={() => router.replace('/login')}
className="py-3 px-6 bg-gradient-to-r from-purple-600 to-pink-600 text-white font-semibold rounded-lg"
>
</button>
</div>
)}
{status === 'checking' && (
<div className="mt-6 text-center">
<button
onClick={() => {
if (pollRef.current) clearInterval(pollRef.current);
router.replace('/login');
}}
className="text-purple-300 hover:text-purple-200 text-sm"
>
</button>
</div>
)}
</div>
);
}
export default function PayPage() {
return (
<div className="min-h-dvh flex items-center justify-center">
<Suspense fallback={
<div className="w-full max-w-md p-8 bg-white/10 backdrop-blur-lg rounded-2xl shadow-2xl border border-white/20 text-center">
<svg className="animate-spin h-12 w-12 mx-auto text-purple-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
</div>
}>
<PayContent />
</Suspense>
</div>
);
}

View File

@@ -61,7 +61,7 @@ export default function RegisterPage() {
</div> </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"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 || '登录失败',
};
}
} }
/** /**

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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