This commit is contained in:
Kevin Wong
2026-02-24 16:55:29 +08:00
parent bc0fe9326a
commit 0a5a17402c
29 changed files with 879 additions and 143 deletions

View File

@@ -82,6 +82,9 @@ backend/
- 标题显示模式参数: - 标题显示模式参数:
- `title_display_mode`: `short` / `persistent`(默认 `short` - `title_display_mode`: `short` / `persistent`(默认 `short`
- `title_duration`: 默认 `4.0`(秒),仅 `short` 模式生效 - `title_duration`: 默认 `4.0`(秒),仅 `short` 模式生效
- 片头副标题参数:
- `secondary_title`: 副标题文字(可选,限 20 字),仅在视频画面中显示,不参与发布标题
- `secondary_title_style_id` / `secondary_title_font_size` / `secondary_title_top_margin`: 副标题样式配置
- workflow/remotion 侧需保持字段透传一致,避免前后端语义漂移。 - workflow/remotion 侧需保持字段透传一致,避免前后端语义漂移。
--- ---
@@ -167,7 +170,6 @@ 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_APP_ID` / `ALIPAY_PRIVATE_KEY_PATH` / `ALIPAY_PUBLIC_KEY_PATH`

View File

@@ -146,6 +146,10 @@ backend/
- `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

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()` 函数,上传路径使用清洗后文件名 |

View File

@@ -52,13 +52,14 @@ ViGent2 的前端界面,采用 Next.js 16 + TailwindCSS 构建。
- **画面比例控制**: 时间轴顶部支持 `9:16 / 16:9` 输出比例选择,设置持久化并透传后端。 - **画面比例控制**: 时间轴顶部支持 `9:16 / 16:9` 输出比例选择,设置持久化并透传后端。
### 5. 字幕与标题 [Day 13 新增] ### 5. 字幕与标题 [Day 13 新增]
- **片头标题**: 可选输入,限制 15 字;支持短暂显示 / 常驻显示”默认短暂显示4 秒)。 - **片头标题**: 可选输入,限制 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 新增]
- **试听预览**: 点击试听即选中,音量滑块实时生效。 - **试听预览**: 点击试听即选中,音量滑块实时生效。
@@ -77,7 +78,8 @@ ViGent2 的前端界面,采用 Next.js 16 + TailwindCSS 构建。
### 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)。
- **一键填入**: 提取结果直接填充至视频生成输入框。 - **一键填入**: 提取结果直接填充至视频生成输入框。
- **智能交互**: 实时进度展示,防误触设计。 - **智能交互**: 实时进度展示,防误触设计。

View File

@@ -2,7 +2,7 @@
**项目**: ViGent2 数字人口播视频生成系统 **项目**: ViGent2 数字人口播视频生成系统
**进度**: 100% (Day 25 - 支付宝付费开通会员) **进度**: 100% (Day 25 - 支付宝付费开通会员)
**更新时间**: 2026-02-11 **更新时间**: 2026-02-24
--- ---
@@ -10,15 +10,15 @@
> 这里记录了每一天的核心开发内容与 milestone。 > 这里记录了每一天的核心开发内容与 milestone。
### Day 25: 支付宝付费开通会员 (Current) ### Day 25: 文案提取修复 + 自定义提示词 + 片头副标题 (Current)
- [x] **支付宝电脑网站支付**: 集成 `python-alipay-sdk`,支持 `alipay.trade.page.pay` 跳转支付宝收银台 - [x] **抖音文案提取修复**: yt-dlp Fresh cookies 报错,重写 `_download_douyin_manual` 为移动端分享页 + 自动获取 ttwid 方案
- [x] **payment_token 机制**: 登录时未激活/已过期用户返回 403 + 短时效 JWT30 分钟),安全传递身份到付费页 - [x] **清理 DOUYIN_COOKIE**: 新方案不再需要手动维护 Cookie`.env`/`config.py`/`service.py` 全面删除
- [x] **异步通知回调**: `POST /api/payment/notify` 验签 → 更新订单 → 激活用户is_active=true, expires_at=+365天 - [x] **AI 智能改写自定义提示词**: 后端 `rewrite_script()` 支持 `custom_prompt` 参数;前端 checkbox 旁新增折叠式提示词编辑区localStorage 持久化
- [x] **前端付费页**: `/pay` 页面,首次访问创建订单并跳转收银台,支付完成返回后轮询状态 - [x] **SSR 构建修复**: `useState` 初始化 `localStorage` 访问加 `typeof window` 守卫,修复 `npm run build` 报错
- [x] **is_active 安全兜底**: `deps.py` 在登录和鉴权两处均检查 is_active到期自动停用并清理 session - [x] **片头副标题**: 新增 secondary_title后端/Remotion/前端全链路AI 同时生成独立样式配置20 字限制
- [x] **orders 数据层**: 新增 `repositories/orders.py` + `orders` 数据库表 - [x] **前端文案修正**: "AI 洗稿结果"→"AI 改写结果"
- [x] **登录流程适配**: 登录接口返回 PAYMENT_REQUIRED前端 auth.ts 处理 paymentToken 跳转 - [x] **yt-dlp 升级**: `2025.12.08``2026.2.21`
- [x] **部署文档**: 新增 `Docs/ALIPAY_DEPLOY.md`含密钥配置、PEM 格式、产品开通等完整指南 - [x] **参考音频中文文件名修复**: `sanitize_filename()` 将存储路径清洗为 ASCII 安全字符,纯中文名哈希兜底,原始名保留为展示名
### Day 24: 鉴权到期治理 + 多素材时间轴稳定性修复 ### Day 24: 鉴权到期治理 + 多素材时间轴稳定性修复
- [x] **会员到期请求时失效**: 登录与鉴权接口统一执行 `expires_at` 检查;到期后自动停用账号、清理 session并返回“会员已到期请续费”。 - [x] **会员到期请求时失效**: 登录与鉴权接口统一执行 `expires_at` 检查;到期后自动停用账号、清理 session并返回“会员已到期请续费”。

View File

@@ -19,14 +19,15 @@
- 🎬 **高清唇形同步** - 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效果) 字幕。
- 🎨 **样式预设** - 标题/字幕样式选择 + 预览 + 字号调节,支持自定义字体库。 - 🎨 **样式预设** - 标题/副标题/字幕样式选择 + 预览 + 字号调节,支持自定义字体库。
- 🏷️ **标题显示模式** - 片头标题支持 `短暂显示` / `常驻显示`默认短暂显示4秒用户偏好自动持久化。 - 🏷️ **标题显示模式** - 片头标题支持 `短暂显示` / `常驻显示`默认短暂显示4秒用户偏好自动持久化。
- 📌 **片头副标题** - 可选副标题显示在主标题下方独立样式配置AI 可同时生成20 字限制。
- 🖼️ **作品预览一致性** - 标题/字幕预览与 Remotion 成片统一响应式缩放和自动换行,窄屏画布也稳定显示。 - 🖼️ **作品预览一致性** - 标题/字幕预览与 Remotion 成片统一响应式缩放和自动换行,窄屏画布也稳定显示。
- 🎞️ **多素材多机位** - 支持多选素材 + 时间轴编辑器 (wavesurfer.js 波形可视化),拖拽分割线调整时长、拖拽排序切换机位、按 `source_start/source_end` 截取片段。 - 🎞️ **多素材多机位** - 支持多选素材 + 时间轴编辑器 (wavesurfer.js 波形可视化),拖拽分割线调整时长、拖拽排序切换机位、按 `source_start/source_end` 截取片段。
- 📐 **画面比例控制** - 时间轴一键切换 `9:16 / 16:9` 输出比例,生成链路全程按目标比例处理。 - 📐 **画面比例控制** - 时间轴一键切换 `9:16 / 16:9` 输出比例,生成链路全程按目标比例处理。
- 💾 **用户偏好持久化** - 首页状态统一恢复/保存,刷新后延续上次配置。历史文案手动保存与加载。 - 💾 **用户偏好持久化** - 首页状态统一恢复/保存,刷新后延续上次配置。历史文案手动保存与加载。
- 🎵 **背景音乐** - 试听 + 音量控制 + 混音,保持配音音量稳定。 - 🎵 **背景音乐** - 试听 + 音量控制 + 混音,保持配音音量稳定。
- 🤖 **AI 辅助创作** - 内置 GLM-4.7-Flash支持 B站/抖音链接文案提取、AI 洗稿、标题/标签自动生成、9 语言翻译。 - 🤖 **AI 辅助创作** - 内置 GLM-4.7-Flash支持 B站/抖音链接文案提取、AI 智能改写(支持自定义提示词)、标题/标签自动生成、9 语言翻译。
### 平台化功能 ### 平台化功能
- 📱 **全自动发布** - 支持抖音/微信视频号/B站/小红书立即发布;扫码登录 + Cookie 持久化。 - 📱 **全自动发布** - 支持抖音/微信视频号/B站/小红书立即发布;扫码登录 + Cookie 持久化。

View File

@@ -71,11 +71,9 @@ 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=******** ALIPAY_APP_ID=2021006132600283
ALIPAY_PRIVATE_KEY_PATH=/home/rongye/ProgramFiles/ViGent2/backend/keys/app_private_key.pem 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_PUBLIC_KEY_PATH=/home/rongye/ProgramFiles/ViGent2/backend/keys/alipay_public_key.pem
ALIPAY_NOTIFY_URL=https://vigent.hbyrkj.top/api/payment/notify ALIPAY_NOTIFY_URL=https://vigent.hbyrkj.top/api/payment/notify

View File

@@ -88,10 +88,6 @@ class Settings(BaseSettings):
# 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

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

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

@@ -26,6 +26,10 @@ class GenerateRequest(BaseModel):
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)
@@ -675,6 +693,8 @@ async def process_video_generation(task_id: str, req: GenerateRequest, user_id:
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

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

@@ -36,6 +36,8 @@ class RemotionService:
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,
secondary_title: Optional[str] = None,
secondary_title_style: Optional[dict] = None,
on_progress: Optional[Callable[[int], None]] = None on_progress: Optional[Callable[[int], None]] = None
) -> str: ) -> str:
""" """
@@ -86,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)}")
# 在线程池中运行子进程 # 在线程池中运行子进程

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

@@ -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";
@@ -157,6 +157,13 @@ export const useHomeController = () => {
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>("");
@@ -430,6 +437,8 @@ export const useHomeController = () => {
setText, setText,
videoTitle, videoTitle,
setVideoTitle, setVideoTitle,
videoSecondaryTitle,
setVideoSecondaryTitle,
ttsMode, ttsMode,
setTtsMode, setTtsMode,
voice, voice,
@@ -442,14 +451,21 @@ 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, titleDisplayMode,
setTitleDisplayMode, setTitleDisplayMode,
subtitleBottomMargin, subtitleBottomMargin,
@@ -491,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;
@@ -582,6 +604,16 @@ 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(() => { ... })
@@ -741,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() }
); );
@@ -751,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) {
@@ -950,6 +986,17 @@ export const useHomeController = () => {
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) {
@@ -1049,6 +1096,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,

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,21 @@ 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'; titleDisplayMode: 'short' | 'persistent';
setTitleDisplayMode: React.Dispatch<React.SetStateAction<'short' | 'persistent'>>; setTitleDisplayMode: React.Dispatch<React.SetStateAction<'short' | 'persistent'>>;
subtitleBottomMargin: number; subtitleBottomMargin: number;
@@ -65,6 +74,8 @@ export const useHomePersistence = ({
setText, setText,
videoTitle, videoTitle,
setVideoTitle, setVideoTitle,
videoSecondaryTitle,
setVideoSecondaryTitle,
ttsMode, ttsMode,
setTtsMode, setTtsMode,
voice, voice,
@@ -77,14 +88,21 @@ 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, titleDisplayMode,
setTitleDisplayMode, setTitleDisplayMode,
subtitleBottomMargin, subtitleBottomMargin,
@@ -112,20 +130,24 @@ 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 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`);
@@ -133,6 +155,7 @@ export const useHomePersistence = ({
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);
@@ -152,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);
@@ -169,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');
@@ -179,6 +211,10 @@ 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') { if (savedTitleDisplayMode === 'short' || savedTitleDisplayMode === 'persistent') {
setTitleDisplayMode(savedTitleDisplayMode); setTitleDisplayMode(savedTitleDisplayMode);
} }
@@ -206,6 +242,7 @@ export const useHomePersistence = ({
setSelectedMaterials, setSelectedMaterials,
setSelectedSubtitleStyleId, setSelectedSubtitleStyleId,
setSelectedTitleStyleId, setSelectedTitleStyleId,
setSelectedSecondaryTitleStyleId,
setSelectedVideoId, setSelectedVideoId,
setSelectedAudioId, setSelectedAudioId,
setSpeed, setSpeed,
@@ -215,12 +252,16 @@ export const useHomePersistence = ({
setTextLang, setTextLang,
setTitleFontSize, setTitleFontSize,
setTitleSizeLocked, setTitleSizeLocked,
setSecondaryTitleFontSize,
setSecondaryTitleSizeLocked,
setTitleTopMargin, setTitleTopMargin,
setSecondaryTitleTopMargin,
setTitleDisplayMode, setTitleDisplayMode,
setSubtitleBottomMargin, setSubtitleBottomMargin,
setOutputAspectRatio, setOutputAspectRatio,
setTtsMode, setTtsMode,
setVideoTitle, setVideoTitle,
setVideoSecondaryTitle,
setVoice, setVoice,
storageKey, storageKey,
]); ]);
@@ -241,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]);
@@ -271,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));
@@ -283,12 +338,24 @@ 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(() => { useEffect(() => {
if (isRestored) { if (isRestored) {
localStorage.setItem(`vigent_${storageKey}_titleDisplayMode`, titleDisplayMode); localStorage.setItem(`vigent_${storageKey}_titleDisplayMode`, titleDisplayMode);

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;
@@ -56,9 +60,13 @@ const DESKTOP_WIDTH = 280;
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,
@@ -126,6 +134,22 @@ 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={{
@@ -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

@@ -70,6 +70,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,
@@ -217,6 +226,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}
@@ -225,6 +238,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}

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>

View File

@@ -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;
@@ -68,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,
@@ -109,9 +129,13 @@ export function TitleSubtitlePanel({
<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}
@@ -153,6 +177,19 @@ export function TitleSubtitlePanel({
/> />
</div> </div>
<div className="mb-4">
<label className="text-sm text-gray-300 mb-2 block">20</label>
<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">
<label className="text-sm text-gray-300 mb-2 block"></label> <label className="text-sm text-gray-300 mb-2 block"></label>
@@ -200,6 +237,53 @@ export function TitleSubtitlePanel({
</div> </div>
)} )}
{titleStyles.length > 0 && (
<div className="mb-4">
<label className="text-sm text-gray-300 mb-2 block"></label>
<div className="grid grid-cols-2 gap-2">
{titleStyles.map((style) => (
<button
key={style.id}
onClick={() => onSelectSecondaryTitleStyle(style.id)}
className={`p-2 rounded-lg border transition-all text-left ${selectedSecondaryTitleStyleId === style.id
? "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>
<div className="text-xs text-gray-400 truncate">
{style.font_family || style.font_file || ""}
</div>
</button>
))}
</div>
<div className="mt-3">
<label className="text-xs text-gray-400 mb-2 block">: {secondaryTitleFontSize}px</label>
<input
type="range"
min="30"
max="100"
step="1"
value={secondaryTitleFontSize}
onChange={(e) => onSecondaryTitleFontSizeChange(parseInt(e.target.value, 10))}
className="w-full accent-purple-500"
/>
</div>
<div className="mt-3">
<label className="text-xs text-gray-400 mb-2 block">: {secondaryTitleTopMargin}px</label>
<input
type="range"
min="0"
max="100"
step="1"
value={secondaryTitleTopMargin}
onChange={(e) => onSecondaryTitleTopMarginChange(parseInt(e.target.value, 10))}
className="w-full accent-purple-500"
/>
</div>
</div>
)}
{subtitleStyles.length > 0 && ( {subtitleStyles.length > 0 && (
<div className="mt-4"> <div className="mt-4">
<label className="text-sm text-gray-300 mb-2 block"></label> <label className="text-sm text-gray-300 mb-2 block"></label>

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

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

@@ -19,6 +19,8 @@ interface RenderOptions {
titleDisplayMode?: 'short' | 'persistent'; 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;
@@ -75,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;
} }
} }
@@ -161,6 +173,8 @@ async function main() {
titleDisplayMode: options.titleDisplayMode || 'short', 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,9 +25,11 @@ export const RemotionRoot: React.FC = () => {
audioSrc: undefined, audioSrc: undefined,
captions: undefined, captions: undefined,
title: undefined, title: undefined,
secondaryTitle: undefined,
titleDuration: 4, titleDuration: 4,
titleDisplayMode: 'short', titleDisplayMode: 'short',
enableSubtitles: true, enableSubtitles: true,
secondaryTitleStyle: undefined,
width: 1080, width: 1080,
height: 1920, height: 1920,
}} }}

View File

@@ -10,11 +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'; 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;
} }
@@ -28,11 +30,13 @@ export const Video: React.FC<VideoProps> = ({
audioSrc, audioSrc,
captions, captions,
title, title,
secondaryTitle,
titleDuration = 4, titleDuration = 4,
titleDisplayMode = 'short', titleDisplayMode = 'short',
enableSubtitles = true, enableSubtitles = true,
subtitleStyle, subtitleStyle,
titleStyle, titleStyle,
secondaryTitleStyle,
}) => { }) => {
return ( return (
<AbsoluteFill style={{ backgroundColor: 'black' }}> <AbsoluteFill style={{ backgroundColor: 'black' }}>
@@ -45,8 +49,15 @@ export const Video: React.FC<VideoProps> = ({
)} )}
{/* 顶层:标题 */} {/* 顶层:标题 */}
{title && ( {(title || secondaryTitle) && (
<Title title={title} duration={titleDuration} displayMode={titleDisplayMode} style={titleStyle} /> <Title
title={title || ''}
secondaryTitle={secondaryTitle}
duration={titleDuration}
displayMode={titleDisplayMode}
style={titleStyle}
secondaryTitleStyle={secondaryTitleStyle}
/>
)} )}
</AbsoluteFill> </AbsoluteFill>
); );

View File

@@ -25,10 +25,12 @@ export interface TitleStyle {
interface TitleProps { interface TitleProps {
title: string; title: string;
secondaryTitle?: string;
duration?: number; // 标题显示时长(秒) duration?: number; // 标题显示时长(秒)
displayMode?: 'short' | 'persistent'; // 短暂显示或常驻显示 displayMode?: 'short' | 'persistent'; // 短暂显示或常驻显示
fadeOutStart?: number; // 开始淡出的时间(秒) fadeOutStart?: number; // 开始淡出的时间(秒)
style?: TitleStyle; style?: TitleStyle;
secondaryTitleStyle?: TitleStyle;
} }
/** /**
@@ -48,17 +50,19 @@ 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,
secondaryTitle,
duration = 4, duration = 4,
displayMode = 'short', displayMode = 'short',
fadeOutStart, fadeOutStart,
style, style,
secondaryTitleStyle,
}) => { }) => {
const frame = useCurrentFrame(); const frame = useCurrentFrame();
const { fps, width } = useVideoConfig(); const { fps, width } = useVideoConfig();
@@ -130,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%',
@@ -149,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)`,
@@ -171,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>
); );
}; };