From 0a5a17402cbbb70c987176221b5a6056a43b04bb Mon Sep 17 00:00:00 2001 From: Kevin Wong Date: Tue, 24 Feb 2026 16:55:29 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Docs/BACKEND_DEV.md | 4 +- Docs/BACKEND_README.md | 4 + Docs/DevLogs/Day25.md | 254 ++++++++++++++++++ Docs/FRONTEND_README.md | 10 +- Docs/task_complete.md | 20 +- README.md | 5 +- backend/.env.example | 4 +- backend/app/core/config.py | 4 - backend/app/modules/ai/router.py | 2 + backend/app/modules/ref_audios/service.py | 14 +- backend/app/modules/tools/router.py | 5 +- backend/app/modules/tools/service.py | 131 +++++---- backend/app/modules/videos/schemas.py | 4 + backend/app/modules/videos/workflow.py | 22 +- backend/app/services/glm_service.py | 24 +- backend/app/services/remotion_service.py | 8 + backend/requirements.txt | 3 + .../features/home/model/useHomeController.ts | 60 ++++- .../features/home/model/useHomePersistence.ts | 69 ++++- .../features/home/ui/FloatingStylePreview.tsx | 88 ++++-- frontend/src/features/home/ui/HomePage.tsx | 22 ++ .../home/ui/ScriptExtractionModal.tsx | 55 +++- .../features/home/ui/TitleSubtitlePanel.tsx | 84 ++++++ .../script-extraction/useScriptExtraction.ts | 25 +- frontend/src/shared/lib/title.ts | 4 + remotion/render.ts | 14 + remotion/src/Root.tsx | 2 + remotion/src/Video.tsx | 15 +- remotion/src/components/Title.tsx | 66 ++++- 29 files changed, 879 insertions(+), 143 deletions(-) create mode 100644 Docs/DevLogs/Day25.md diff --git a/Docs/BACKEND_DEV.md b/Docs/BACKEND_DEV.md index d50a188..51f92ed 100644 --- a/Docs/BACKEND_DEV.md +++ b/Docs/BACKEND_DEV.md @@ -82,6 +82,9 @@ backend/ - 标题显示模式参数: - `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 侧需保持字段透传一致,避免前后端语义漂移。 --- @@ -167,7 +170,6 @@ backend/user_data/{user_uuid}/cookies/ - `DOUYIN_LOCALE` / `DOUYIN_TIMEZONE_ID` - `DOUYIN_FORCE_SWIFTSHADER` - `DOUYIN_DEBUG_ARTIFACTS` / `DOUYIN_RECORD_VIDEO` / `DOUYIN_KEEP_SUCCESS_VIDEO` -- `DOUYIN_COOKIE` (抖音视频下载 Cookie) ### 支付宝 - `ALIPAY_APP_ID` / `ALIPAY_PRIVATE_KEY_PATH` / `ALIPAY_PUBLIC_KEY_PATH` diff --git a/Docs/BACKEND_README.md b/Docs/BACKEND_README.md index 1c779cb..56efce1 100644 --- a/Docs/BACKEND_README.md +++ b/Docs/BACKEND_README.md @@ -146,6 +146,10 @@ backend/ - `subtitle_font_size`: 字幕字号(覆盖样式默认值) - `title_font_size`: 标题字号(覆盖样式默认值) - `title_top_margin`: 标题距顶部像素 +- `secondary_title`: 片头副标题文字(可选,限 20 字,仅视频画面显示) +- `secondary_title_style_id`: 副标题样式 ID +- `secondary_title_font_size`: 副标题字号 +- `secondary_title_top_margin`: 副标题距主标题间距 - `subtitle_bottom_margin`: 字幕距底部像素 - `enable_subtitles`: 是否启用字幕 - `bgm_id`: 背景音乐 ID diff --git a/Docs/DevLogs/Day25.md b/Docs/DevLogs/Day25.md new file mode 100644 index 0000000..98086b2 --- /dev/null +++ b/Docs/DevLogs/Day25.md @@ -0,0 +1,254 @@ +## 🔧 文案提取助手修复 — 抖音链接无法提取文案 (Day 25) + +### 概述 + +文案提取助手粘贴抖音链接后无法提取文案,yt-dlp 报错 `Fresh cookies are needed`,手动回退方案也因抖音页面结构变化失效。本日完成了完整修复,并清理了不再需要的 `DOUYIN_COOKIE` 配置。 + +--- + +## 🐛 问题诊断 + +### 错误链路 + +1. **yt-dlp 失败**:`ERROR: [Douyin] Fresh cookies (not necessarily logged in) are needed` + - yt-dlp 版本 `2025.12.08` 过旧 + - 抖音 API `aweme/v1/web/aweme/detail/` 需要签名 cookie(`s_v_web_id` 等),即使升级 yt-dlp 到最新版 + 传入 cookie 仍无法解决,属 yt-dlp 已知问题 +2. **手动回退失败**:`Could not find RENDER_DATA in page` + - 旧方案通过桌面端用户主页 + `modal_id` 访问,抖音 SSR 已不再返回 `videoDetail` 数据 +3. **`.env` 中 `DOUYIN_COOKIE`**:时间戳 2024 年 12 月,早已过期 + +--- + +## ✅ 修复方案:移动端分享页 + 自动获取 ttwid + +### 核心思路 + +放弃依赖 yt-dlp 下载抖音视频和手动维护 cookie,改为: + +1. 自动从 ByteDance 公共 API 获取新鲜 `ttwid`(匿名令牌,不绑定账号) +2. 用 `ttwid` 访问移动端分享页 `m.douyin.com/share/video/{id}` +3. 从页面内嵌 JSON 中提取 `play_addr` 播放地址并下载 + +### 关键代码(`_download_douyin_manual` 重写) + +```python +# 1. 获取新鲜 ttwid +ttwid_resp = await client.post( + "https://ttwid.bytedance.com/ttwid/union/register/", + json={"region": "cn", "aid": 6383, "service": "www.douyin.com", ...} +) +ttwid = ttwid_resp.cookies.get("ttwid", "") + +# 2. 访问移动端分享页 +page_resp = await client.get( + f"https://m.douyin.com/share/video/{video_id}", + headers={"cookie": f"ttwid={ttwid}", ...} +) + +# 3. 提取 play_addr +addr_match = re.search(r'"play_addr":\{"uri":"([^"]+)","url_list":\["([^"]+)"', page_text) +video_url = addr_match.group(2).replace(r"\u002F", "/") +``` + +### 优势 + +- 不再依赖手动维护的 `DOUYIN_COOKIE`,ttwid 每次请求自动获取 +- 不受 yt-dlp 对抖音支持状况影响 +- 所有用户通用,不绑定特定账号 + +--- + +## 🧹 清理 DOUYIN_COOKIE 配置 + +`DOUYIN_COOKIE` 仅用于文案提取,新方案不再需要,已从以下位置删除: + +| 文件 | 变更 | +|------|------| +| `backend/.env` | 删除 `DOUYIN_COOKIE` 配置项及注释 | +| `backend/app/core/config.py` | 删除 `DOUYIN_COOKIE: str = ""` 字段定义 | +| `backend/app/modules/tools/service.py` | 删除 yt-dlp 传 cookie 逻辑和 `_write_netscape_cookies` 辅助函数 | + +--- + +## 🔤 前端文案修正 + +将文案提取界面中的"AI 洗稿结果"改为"AI 改写结果"。 + +| 文件 | 变更 | +|------|------| +| `frontend/src/features/home/ui/ScriptExtractionModal.tsx` | `AI 洗稿结果` → `AI 改写结果` | +| `backend/app/modules/tools/service.py` | 注释中"洗稿"→"改写" | +| `backend/app/services/glm_service.py` | docstring 中"洗稿"→"改写文案" | + +--- + +## 📦 其他变更 + +- **yt-dlp 升级**:`2025.12.08` → `2026.2.21` +- **yt-dlp 初始化修正**:改为 `YoutubeDL(ydl_opts)` 直接传参初始化(原先空初始化后 update params 不生效) +- **User-Agent 更新**:yt-dlp 中 `Chrome/91` → `Chrome/131` + +--- + +## 涉及文件汇总 + +### 后端修改 + +| 文件 | 变更 | +|------|------| +| `backend/app/modules/tools/service.py` | 重写 `_download_douyin_manual`(移动端分享页方案);修正 yt-dlp 初始化;清理 cookie 相关代码;注释改写 | +| `backend/app/services/glm_service.py` | docstring "洗稿" → "改写文案" | +| `backend/app/core/config.py` | 删除 `DOUYIN_COOKIE` 字段 | +| `backend/.env` | 删除 `DOUYIN_COOKIE` 配置 | +| `backend/requirements.txt` | yt-dlp 版本升级 | + +### 前端修改 + +| 文件 | 变更 | +|------|------| +| `frontend/src/features/home/ui/ScriptExtractionModal.tsx` | "AI 洗稿结果" → "AI 改写结果" | + +--- + +## ✏️ AI 智能改写 — 自定义提示词功能 + +### 概述 + +文案提取助手的"AI 智能改写"原先使用硬编码 prompt,用户无法定制改写风格。本次在 checkbox 右侧新增"自定义提示词"折叠区域,用户可编辑自定义 prompt,持久化到 localStorage,后端按需替换默认 prompt。 + +### 后端修改 + +**路由层** (`router.py`):`extract_script_tool` 新增可选 Form 参数 `custom_prompt: Optional[str] = Form(None)`,透传给 service。 + +**服务层** (`service.py`):`extract_script()` 签名新增 `custom_prompt`,透传给 `glm_service.rewrite_script(script, custom_prompt)`。 + +**AI 层** (`glm_service.py`):`rewrite_script(self, text, custom_prompt=None)`,若 `custom_prompt` 有值则用自定义 prompt + 原文拼接,否则保持原有默认 prompt。 + +```python +if custom_prompt and custom_prompt.strip(): + prompt = f"""{custom_prompt.strip()} + +原始文案: +{text}""" +else: + prompt = f"""请将以下视频文案进行改写。...(原有默认)""" +``` + +### 前端修改 + +**Hook** (`useScriptExtraction.ts`): +- 新增 `customPrompt` / `showCustomPrompt` 状态 +- 初始值从 `localStorage.getItem("vigent_rewriteCustomPrompt")` 恢复 +- `customPrompt` 变化时防抖 300ms 保存到 localStorage +- `handleExtract()` 中若 `doRewrite && customPrompt.trim()` 有值,追加 `formData.append("custom_prompt", ...)` +- modal 重置时不清空 customPrompt(持久化偏好) + +**UI** (`ScriptExtractionModal.tsx`): +- checkbox 同行右侧新增"自定义提示词 ▼"按钮(仅 `doRewrite` 时显示) +- 点击展开 textarea 编辑区域,底部提示"留空则使用默认提示词" +- 取消勾选 AI 智能改写时,自定义提示词区域自动隐藏 + +### 涉及文件 + +| 文件 | 变更 | +|------|------| +| `backend/app/modules/tools/router.py` | 新增 `custom_prompt` Form 参数 | +| `backend/app/modules/tools/service.py` | `extract_script()` 透传 `custom_prompt` | +| `backend/app/services/glm_service.py` | `rewrite_script()` 支持自定义 prompt | +| `frontend/.../useScriptExtraction.ts` | 新增状态、localStorage 持久化、FormData 传参 | +| `frontend/.../ScriptExtractionModal.tsx` | UI 按钮 + 展开 textarea | + +### 验证 + +- 后端 `python -m py_compile` 三个文件通过 +- 前端 `npx tsc --noEmit` 通过 + +--- + +## 🐛 SSR 构建修复 — localStorage is not defined + +### 问题 + +`npm run build` 报错 `ReferenceError: localStorage is not defined`,因为 `useScriptExtraction.ts` 中 `useState` 的初始化函数在 SSR(Node.js)环境下也会执行,而服务端没有 `localStorage`。 + +### 修复 + +`useState` 初始化加 `typeof window !== "undefined"` 守卫: + +```typescript +const [customPrompt, setCustomPrompt] = useState( + () => typeof window !== "undefined" ? localStorage.getItem(CUSTOM_PROMPT_KEY) || "" : "" +); +``` + +| 文件 | 变更 | +|------|------| +| `frontend/.../useScriptExtraction.ts` | `useState` 初始化增加 SSR 安全守卫 | + +--- + +## 🎬 片头副标题功能 + +### 概述 + +新增片头副标题(secondary_title),显示在主标题下方,用于补充说明或悬念引导。副标题有独立的样式配置(字体、字号、颜色等),可由 AI 同时生成,20 字限制,仅在视频画面中显示,不参与发布标题。 + +命名约定:后端 `secondary_title`(snake_case),前端 `videoSecondaryTitle`(camelCase),用户界面"片头副标题"。 + +--- + +### 后端修改 + +| 文件 | 变更 | +|------|------| +| `backend/app/modules/videos/schemas.py` | `GenerateRequest` 新增 4 个可选字段:`secondary_title`、`secondary_title_style_id`、`secondary_title_font_size`、`secondary_title_top_margin` | +| `backend/app/services/glm_service.py` | AI prompt 增加副标题生成要求(不超过20字),JSON 格式新增 `secondary_title` 字段 | +| `backend/app/modules/ai/router.py` | `GenerateMetaResponse` 增加 `secondary_title: str = ""`,endpoint 返回时取 `result.get("secondary_title", "")` | +| `backend/app/modules/videos/workflow.py` | `use_remotion` 条件增加 `or req.secondary_title`;副标题样式解析复用 `get_style("title", ...)`;字号/间距覆盖;`prepare_style_for_remotion` 处理副标题字体;`remotion_service.render()` 传入 `secondary_title` + `secondary_title_style` | +| `backend/app/services/remotion_service.py` | `render()` 新增 `secondary_title` 和 `secondary_title_style` 参数,构建 CLI 参数 `--secondaryTitle` 和 `--secondaryTitleStyle` | + +### Remotion 修改 + +| 文件 | 变更 | +|------|------| +| `remotion/render.ts` | `RenderOptions` 新增 `secondaryTitle?` + `secondaryTitleStyle?`;`parseArgs()` 新增 switch case;`inputProps` 新增两个字段 | +| `remotion/src/components/Title.tsx` | `TitleProps` 新增 `secondaryTitle?` 和 `secondaryTitleStyle?`;`AbsoluteFill` 改为 `flexDirection: 'column'` 垂直堆叠;主标题 `

` 后增加副标题 `

`,独立样式(默认字号 48px、字重 700),共享淡入淡出动画;副标题字体使用独立 `@font-face`(`SecondaryTitleFont`)避免与主标题冲突 | +| `remotion/src/Video.tsx` | `VideoProps` 新增 `secondaryTitle?` + `secondaryTitleStyle?`;传递给 `` 组件;渲染条件改为 `{(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()` 函数,上传路径使用清洗后文件名 | diff --git a/Docs/FRONTEND_README.md b/Docs/FRONTEND_README.md index f903b3a..0eacd5c 100644 --- a/Docs/FRONTEND_README.md +++ b/Docs/FRONTEND_README.md @@ -52,13 +52,14 @@ ViGent2 的前端界面,采用 Next.js 16 + TailwindCSS 构建。 - **画面比例控制**: 时间轴顶部支持 `9:16 / 16:9` 输出比例选择,设置持久化并透传后端。 ### 5. 字幕与标题 [Day 13 新增] -- **片头标题**: 可选输入,限制 15 字;支持“短暂显示 / 常驻显示”,默认短暂显示(4 秒)。 +- **片头标题**: 可选输入,限制 15 字;支持”短暂显示 / 常驻显示”,默认短暂显示(4 秒)。 +- **片头副标题**: 可选输入,限制 20 字;显示在主标题下方,用于补充说明或悬念引导;独立样式配置(字体/字号/颜色/间距),可由 AI 同时生成;仅在视频画面中显示,不参与发布标题 (Day 25)。 - **标题同步**: 首页片头标题修改会同步到发布信息标题。 - **逐字高亮字幕**: 卡拉OK效果,默认开启,可关闭。 - **自动对齐**: 基于 faster-whisper 生成字级别时间戳。 -- **样式预设**: 标题/字幕样式选择 + 预览 + 字号调节 (Day 16)。 +- **样式预设**: 标题/字幕/副标题样式选择 + 预览 + 字号调节 (Day 16/25)。 - **默认样式**: 标题 90px 站酷快乐体;字幕 60px 经典黄字 + DingTalkJinBuTi (Day 17)。 -- **样式持久化**: 标题/字幕样式与字号刷新保留 (Day 17)。 +- **样式持久化**: 标题/字幕/副标题样式与字号刷新保留 (Day 17/25)。 ### 6. 背景音乐 [Day 16 新增] - **试听预览**: 点击试听即选中,音量滑块实时生效。 @@ -77,7 +78,8 @@ ViGent2 的前端界面,采用 Next.js 16 + TailwindCSS 构建。 ### 8. 文案提取助手 (`ScriptExtractionModal`) [Day 15 新增] - **多源提取**: 支持文件拖拽上传与 URL 粘贴 (B站/抖音/TikTok)。 -- **AI 洗稿**: 集成 GLM-4.7-Flash,自动改写为口播文案。 +- **AI 智能改写**: 集成 GLM-4.7-Flash,自动改写为口播文案。 +- **自定义提示词**: 可自定义改写提示词,留空使用默认;设置持久化到 localStorage (Day 25)。 - **一键填入**: 提取结果直接填充至视频生成输入框。 - **智能交互**: 实时进度展示,防误触设计。 diff --git a/Docs/task_complete.md b/Docs/task_complete.md index 663b19e..78e21f2 100644 --- a/Docs/task_complete.md +++ b/Docs/task_complete.md @@ -2,7 +2,7 @@ **项目**: ViGent2 数字人口播视频生成系统 **进度**: 100% (Day 25 - 支付宝付费开通会员) -**更新时间**: 2026-02-11 +**更新时间**: 2026-02-24 --- @@ -10,15 +10,15 @@ > 这里记录了每一天的核心开发内容与 milestone。 -### Day 25: 支付宝付费开通会员 (Current) -- [x] **支付宝电脑网站支付**: 集成 `python-alipay-sdk`,支持 `alipay.trade.page.pay` 跳转支付宝收银台。 -- [x] **payment_token 机制**: 登录时未激活/已过期用户返回 403 + 短时效 JWT(30 分钟),安全传递身份到付费页。 -- [x] **异步通知回调**: `POST /api/payment/notify` 验签 → 更新订单 → 激活用户(is_active=true, expires_at=+365天)。 -- [x] **前端付费页**: `/pay` 页面,首次访问创建订单并跳转收银台,支付完成返回后轮询状态。 -- [x] **is_active 安全兜底**: `deps.py` 在登录和鉴权两处均检查 is_active,到期自动停用并清理 session。 -- [x] **orders 数据层**: 新增 `repositories/orders.py` + `orders` 数据库表。 -- [x] **登录流程适配**: 登录接口返回 PAYMENT_REQUIRED,前端 auth.ts 处理 paymentToken 跳转。 -- [x] **部署文档**: 新增 `Docs/ALIPAY_DEPLOY.md`,含密钥配置、PEM 格式、产品开通等完整指南。 +### Day 25: 文案提取修复 + 自定义提示词 + 片头副标题 (Current) +- [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,并返回“会员已到期,请续费”。 diff --git a/README.md b/README.md index 566fe38..aab92e2 100644 --- a/README.md +++ b/README.md @@ -19,14 +19,15 @@ - 🎬 **高清唇形同步** - LatentSync 1.6 驱动,512×512 高分辨率 Latent Diffusion 模型。 - 🎙️ **多模态配音** - 支持 **EdgeTTS** (微软超自然语音, 10 语言) 和 **CosyVoice 3.0** (3秒极速声音克隆, 9语言+18方言, 语速可调)。上传参考音频自动 Whisper 转写 + 智能截取。配音前置工作流:先生成配音 → 选素材 → 生成视频。 - 📝 **智能字幕** - 集成 faster-whisper + Remotion,自动生成逐字高亮 (卡拉OK效果) 字幕。 -- 🎨 **样式预设** - 标题/字幕样式选择 + 预览 + 字号调节,支持自定义字体库。 +- 🎨 **样式预设** - 标题/副标题/字幕样式选择 + 预览 + 字号调节,支持自定义字体库。 - 🏷️ **标题显示模式** - 片头标题支持 `短暂显示` / `常驻显示`,默认短暂显示(4秒),用户偏好自动持久化。 +- 📌 **片头副标题** - 可选副标题显示在主标题下方,独立样式配置,AI 可同时生成,20 字限制。 - 🖼️ **作品预览一致性** - 标题/字幕预览与 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 持久化。 diff --git a/backend/.env.example b/backend/.env.example index f57e5fd..30f3f49 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -71,11 +71,9 @@ GLM_MODEL=glm-4.7-flash SUPABASE_STORAGE_LOCAL_PATH=/home/rongye/ProgramFiles/Supabase/volumes/storage/stub/stub # =============== 抖音视频下载 Cookie =============== -# 用于从抖音 URL 提取视频文案功能,会过期需要定期更新 -DOUYIN_COOKIE=douyin.com; device_web_cpu_core=10; device_web_memory_size=8; __ac_nonce=06760391f00b9b51264ae; __ac_signature=_02B4Z6wo00f019a5ceAAAIDAhEZR-X3jjWfWmXVAAJLXd4; ttwid=1%7C7MTKBSMsP4eOv9h5NAh8p0E-NYIud09ftNmB0mjLpWc%7C1734359327%7C8794abeabbd47447e1f56e5abc726be089f2a0344d6343b5f75f23e7b0f0028f; UIFID_TEMP=0de8750d2b188f4235dbfd208e44abbb976428f0720eb983255afefa45d39c0c6532e1d4768dd8587bf919f866ff1396912bcb2af71efee56a14a2a9f37b74010d0a0413795262f6d4afe02a032ac7ab; s_v_web_id=verify_m4r4ribr_c7krmY1z_WoeI_43po_ATpO_I4o8U1bex2D7; hevc_supported=true; home_can_add_dy_2_desktop=%220%22; dy_swidth=2560; dy_sheight=1440; stream_recommend_feed_params=%22%7B%5C%22cookie_enabled%5C%22%3Atrue%2C%5C%22screen_width%5C%22%3A2560%2C%5C%22screen_height%5C%22%3A1440%2C%5C%22browser_online%5C%22%3Atrue%2C%5C%22cpu_core_num%5C%22%3A10%2C%5C%22device_memory%5C%22%3A8%2C%5C%22downlink%5C%22%3A10%2C%5C%22effective_type%5C%22%3A%5C%224g%5C%22%2C%5C%22round_trip_time%5C%22%3A50%7D%22; strategyABtestKey=%221734359328.577%22; csrf_session_id=2f53aed9aa6974e83aa9a1014180c3a4; fpk1=U2FsdGVkX1/IpBh0qdmlKAVhGyYHgur4/VtL9AReZoeSxadXn4juKvsakahRGqjxOPytHWspYoBogyhS/V6QSw==; fpk2=0845b309c7b9b957afd9ecf775a4c21f; passport_csrf_token=d80e0c5b2fa2328219856be5ba7e671e; passport_csrf_token_default=d80e0c5b2fa2328219856be5ba7e671e; odin_tt=3c891091d2eb0f4718c1d5645bc4a0017032d4d5aa989decb729e9da2ad570918cbe5e9133dc6b145fa8c758de98efe32ff1f81aa0d611e838cc73ab08ef7d3f6adf66ab4d10e8372ddd628f94f16b8e; volume_info=%7B%22isUserMute%22%3Afalse%2C%22isMute%22%3Afalse%2C%22volume%22%3A0.5%7D; bd_ticket_guard_client_web_domain=2; FORCE_LOGIN=%7B%22videoConsumedRemainSeconds%22%3A180%7D; UIFID=0de8750d2b188f4235dbfd208e44abbb976428f0720eb983255afefa45d39c0c6532e1d4768dd8587bf919f866ff139655a3c2b735923234f371c699560c657923fd3d6c5b63ab7bb9b83423b6cb4787e2ce66a7fbc4ecb24c8570f520fe6de068bbb95115023c0c6c1b6ee31b49fb7e3996fb8349f43a3fd8b7a61cd9e18e8fe65eb6a7c13de4c0960d84e344b644725db3eb2fa6b7caf821de1b50527979f2; is_dash_user=1; biz_trace_id=b57a241f; bd_ticket_guard_client_data=eyJiZC10aWNrZXQtZ3VhcmQtdmVyc2lvbiI6MiwiYmQtdGlja2V0LWd1YXJkLWl0ZXJhdGlvbi12ZXJzaW9uIjoxLCJiZC10aWNrZXQtZ3VhcmQtcmVlLXB1YmxpYy1rZXkiOiJCTEo2R0lDalVoWW1XcHpGOFdrN0Vrc0dXcCtaUzNKY1g4NGNGY2k0TTl1TEowNjdUb21mbFU5aDdvWVBGamhNRWNRQWtKdnN1MnM3RmpTWnlJQXpHMjA9IiwiYmQtdGlja2V0LWd1YXJkLXdlYi12ZXJzaW9uIjoyfQ%3D%3D; download_guide=%221%2F20241216%2F0%22; sdk_source_info=7e276470716a68645a606960273f276364697660272927676c715a6d6069756077273f276364697660272927666d776a68605a607d71606b766c6a6b5a7666776c7571273f275e58272927666a6b766a69605a696c6061273f27636469766027292762696a6764695a7364776c6467696076273f275e5827292771273f273d33323131333c3036313632342778; bit_env=RiOY4jzzpxZoVCl6zdVSVhVRjdwHRTxqcqWdqMBZLPGjMdB4Tax1kAELHNTVAAh72KuhumewE4Lq6f0-VJ2UpJrkrhSxoPw9LUb3zQrq1OSwbeSPHkRlRgRQvO89sItdGUyq1oFr0XyRCnMYG87KSeWyc4x0czGR0o50hTDoDLG5rJVoRcdQOLvjiAegsqyytKF59sPX_QM9qffK2SqYsg0hCggURc_AI6kguDDE5DvG0bnyz1utw4z1eEnIoLrkGDqzqBZj4dOAr0BVU6ofbsS-pOQ2u2PM1dLP9FlBVBlVaqYVgHJeSLsR5k76BRTddUjTb4zEilVIEwAMJWGN4I1BxVt6fC9B5tBQpuT0lj3n3eKXCKXZsd8FrEs5_pbfDsxV-e_WMiXI2ff4qxiTC0U73sfo9OpicKICtZjdq8qsHxJuu6wVR36zvXeL2Wch5C6MzprNvkivv0l8nbh2mSgy1nabZr3dmU6NcR-Bg3Q3xTWUlR9aAUmpopC-cNuXjgLpT-Lw1AYGilSUnCvosth1Gfypq-b0MpgmdSDgTrQ%3D; gulu_source_res=eyJwX2luIjoiMDhjOGQ3ZTJiODQyNjZkZWI5Y2VkMGJiODNlNmY1ZWY0ZjMyNTE2ZmYyZjAzNDMzZjI0OWU1Y2Q1NTczNTk5NyJ9; passport_auth_mix_state=hp9bc3dgb1tm5wd8p82zawus27g0e3ue; IsDouyinActive=false # =============== 支付宝配置 =============== -ALIPAY_APP_ID=******** +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 diff --git a/backend/app/core/config.py b/backend/app/core/config.py index dc21846..af905d7 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -88,10 +88,6 @@ class Settings(BaseSettings): # CORS 配置 (逗号分隔的域名列表,* 表示允许所有) CORS_ORIGINS: str = "*" - - # 抖音 Cookie (用于视频下载功能,会过期需要定期更新) - DOUYIN_COOKIE: str = "" - @property def LATENTSYNC_DIR(self) -> Path: """LatentSync 目录路径 (动态计算)""" diff --git a/backend/app/modules/ai/router.py b/backend/app/modules/ai/router.py index 5d4731b..eed5e53 100644 --- a/backend/app/modules/ai/router.py +++ b/backend/app/modules/ai/router.py @@ -21,6 +21,7 @@ class GenerateMetaRequest(BaseModel): class GenerateMetaResponse(BaseModel): """生成标题标签响应""" title: str + secondary_title: str = "" tags: list[str] @@ -66,6 +67,7 @@ async def generate_meta(req: GenerateMetaRequest): result = await glm_service.generate_title_tags(req.text) return success_response(GenerateMetaResponse( title=result.get("title", ""), + secondary_title=result.get("secondary_title", ""), tags=result.get("tags", []) ).model_dump()) except Exception as e: diff --git a/backend/app/modules/ref_audios/service.py b/backend/app/modules/ref_audios/service.py index e0552a1..a5a36b1 100644 --- a/backend/app/modules/ref_audios/service.py +++ b/backend/app/modules/ref_audios/service.py @@ -2,9 +2,11 @@ import re import os import time import json +import hashlib import asyncio import subprocess import tempfile +import unicodedata from pathlib import Path from typing import Optional @@ -19,8 +21,16 @@ BUCKET_REF_AUDIOS = "ref-audios" def sanitize_filename(filename: str) -> str: - """清理文件名,移除特殊字符""" - safe_name = re.sub(r'[<>:"/\\|?*\s]', '_', filename) + """清理文件名用于 Storage key(仅保留 ASCII 安全字符)。""" + normalized = unicodedata.normalize("NFKD", filename) + ascii_name = normalized.encode("ascii", "ignore").decode("ascii") + safe_name = re.sub(r"[^A-Za-z0-9._-]+", "_", ascii_name).strip("._-") + + # 纯中文/emoji 等场景会被清空,使用稳定哈希兜底,避免 InvalidKey + if not safe_name: + digest = hashlib.md5(filename.encode("utf-8")).hexdigest()[:12] + safe_name = f"audio_{digest}" + if len(safe_name) > 50: ext = Path(safe_name).suffix safe_name = safe_name[:50 - len(ext)] + ext diff --git a/backend/app/modules/tools/router.py b/backend/app/modules/tools/router.py index c45293e..b265dd6 100644 --- a/backend/app/modules/tools/router.py +++ b/backend/app/modules/tools/router.py @@ -13,11 +13,12 @@ router = APIRouter() async def extract_script_tool( file: Optional[UploadFile] = File(None), url: Optional[str] = Form(None), - rewrite: bool = Form(True) + rewrite: bool = Form(True), + custom_prompt: Optional[str] = Form(None) ): """独立文案提取工具""" try: - result = await service.extract_script(file=file, url=url, rewrite=rewrite) + result = await service.extract_script(file=file, url=url, rewrite=rewrite, custom_prompt=custom_prompt) return success_response(result) except ValueError as e: raise HTTPException(400, str(e)) diff --git a/backend/app/modules/tools/service.py b/backend/app/modules/tools/service.py index e87ba33..a271fe4 100644 --- a/backend/app/modules/tools/service.py +++ b/backend/app/modules/tools/service.py @@ -17,9 +17,9 @@ from app.services.whisper_service import whisper_service from app.services.glm_service import glm_service -async def extract_script(file=None, url: Optional[str] = None, rewrite: bool = True) -> dict: +async def extract_script(file=None, url: Optional[str] = None, rewrite: bool = True, custom_prompt: Optional[str] = None) -> dict: """ - 文案提取:上传文件或视频链接 -> Whisper 转写 -> (可选) GLM 洗稿 + 文案提取:上传文件或视频链接 -> Whisper 转写 -> (可选) GLM 改写 """ if not file and not url: raise ValueError("必须提供文件或视频链接") @@ -63,11 +63,11 @@ async def extract_script(file=None, url: Optional[str] = None, rewrite: bool = T # 2. 提取文案 (Whisper) script = await whisper_service.transcribe(str(audio_path)) - # 3. AI 洗稿 (GLM) + # 3. AI 改写 (GLM) rewritten = None if rewrite and script and len(script.strip()) > 0: logger.info("Rewriting script...") - rewritten = await glm_service.rewrite_script(script) + rewritten = await glm_service.rewrite_script(script, custom_prompt) return { "original_script": script, @@ -156,125 +156,120 @@ def _download_yt_dlp(url_value: str, temp_dir: Path, timestamp: int) -> Path: 'quiet': True, 'no_warnings': True, 'http_headers': { - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36', + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36', 'Referer': 'https://www.douyin.com/', } } - with yt_dlp.YoutubeDL() as ydl_raw: - ydl: Any = ydl_raw - ydl.params.update(ydl_opts) + with yt_dlp.YoutubeDL(ydl_opts) as ydl: info = ydl.extract_info(url_value, download=True) if 'requested_downloads' in info: downloaded_file = info['requested_downloads'][0]['filepath'] else: ext = info.get('ext', 'mp4') - id = info.get('id') - downloaded_file = str(temp_dir / f"tool_download_{timestamp}_{id}.{ext}") + vid_id = info.get('id') + downloaded_file = str(temp_dir / f"tool_download_{timestamp}_{vid_id}.{ext}") return Path(downloaded_file) async def _download_douyin_manual(url: str, temp_dir: Path, timestamp: int) -> Optional[Path]: - """手动下载抖音视频 (Fallback)""" - logger.info(f"[SuperIPAgent] Starting download for: {url}") + """手动下载抖音视频 (Fallback) — 通过移动端分享页获取播放地址""" + logger.info(f"[douyin-fallback] Starting download for: {url}") try: + # 1. 解析短链接,提取视频 ID headers = { - "user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36" + "user-agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15" } async with httpx.AsyncClient(follow_redirects=True, timeout=10.0) as client: resp = await client.get(url, headers=headers) final_url = str(resp.url) - logger.info(f"[SuperIPAgent] Final URL: {final_url}") + logger.info(f"[douyin-fallback] Final URL: {final_url}") - modal_id = None + video_id = None match = re.search(r'/video/(\d+)', final_url) if match: - modal_id = match.group(1) + video_id = match.group(1) - if not modal_id: - logger.error("[SuperIPAgent] Could not extract modal_id") + if not video_id: + logger.error("[douyin-fallback] Could not extract video_id") return None - logger.info(f"[SuperIPAgent] Extracted modal_id: {modal_id}") + logger.info(f"[douyin-fallback] Extracted video_id: {video_id}") - target_url = f"https://www.douyin.com/user/MS4wLjABAAAAN_s_hups7LD0N4qnrM3o2gI0vuG3pozNaEolz2_py3cHTTrpVr1Z4dukFD9SOlwY?from_tab_name=main&modal_id={modal_id}" + # 2. 获取新鲜 ttwid + ttwid = "" + try: + async with httpx.AsyncClient(timeout=10.0) as client: + ttwid_resp = await client.post( + "https://ttwid.bytedance.com/ttwid/union/register/", + json={ + "region": "cn", "aid": 6383, "needFid": False, + "service": "www.douyin.com", + "migrate_info": {"ticket": "", "source": "node"}, + "cbUrlProtocol": "https", "union": True, + } + ) + ttwid = ttwid_resp.cookies.get("ttwid", "") + logger.info(f"[douyin-fallback] Got fresh ttwid (len={len(ttwid)})") + except Exception as e: + logger.warning(f"[douyin-fallback] Failed to get ttwid: {e}") - from app.core.config import settings - if not settings.DOUYIN_COOKIE: - logger.warning("[SuperIPAgent] DOUYIN_COOKIE 未配置,视频下载可能失败") - - headers_with_cookie = { - "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", - "cookie": settings.DOUYIN_COOKIE, - "user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + # 3. 访问移动端分享页提取播放地址 + page_headers = { + "user-agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15", + "cookie": f"ttwid={ttwid}" if ttwid else "", } - logger.info(f"[SuperIPAgent] Requesting page with Cookie...") + async with httpx.AsyncClient(follow_redirects=True, timeout=15.0) as client: + page_resp = await client.get( + f"https://m.douyin.com/share/video/{video_id}", + headers=page_headers, + ) - async with httpx.AsyncClient(timeout=10.0) as client: - response = await client.get(target_url, headers=headers_with_cookie) + page_text = page_resp.text + logger.info(f"[douyin-fallback] Mobile page length: {len(page_text)}") - content_match = re.findall(r'<script id="RENDER_DATA" type="application/json">(.*?)</script>', response.text) - if not content_match: - if "SSR_HYDRATED_DATA" in response.text: - content_match = re.findall(r'<script id="SSR_HYDRATED_DATA" type="application/json">(.*?)</script>', response.text) - - if not content_match: - logger.error(f"[SuperIPAgent] Could not find RENDER_DATA in page (len={len(response.text)})") - return None - - content = unquote(content_match[0]) - try: - data = json.loads(content) - except: - logger.error("[SuperIPAgent] JSON decode failed") - return None - - video_url = None - try: - if "app" in data and "videoDetail" in data["app"]: - info = data["app"]["videoDetail"]["video"] - if "bitRateList" in info and info["bitRateList"]: - video_url = info["bitRateList"][0]["playAddr"][0]["src"] - elif "playAddr" in info and info["playAddr"]: - video_url = info["playAddr"][0]["src"] - except Exception as e: - logger.error(f"[SuperIPAgent] Path extraction failed: {e}") - - if not video_url: - logger.error("[SuperIPAgent] No video_url found") + # 4. 提取 play_addr + addr_match = re.search( + r'"play_addr":\{"uri":"([^"]+)","url_list":\["([^"]+)"', + page_text, + ) + if not addr_match: + logger.error("[douyin-fallback] Could not find play_addr in mobile page") return None + video_url = addr_match.group(2).replace(r"\u002F", "/") if video_url.startswith("//"): video_url = "https:" + video_url - logger.info(f"[SuperIPAgent] Found video URL: {video_url[:50]}...") + logger.info(f"[douyin-fallback] Found video URL: {video_url[:80]}...") + # 5. 下载视频 temp_path = temp_dir / f"douyin_manual_{timestamp}.mp4" download_headers = { - 'Referer': 'https://www.douyin.com/', - 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36', + "Referer": "https://www.douyin.com/", + "User-Agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15", } - async with httpx.AsyncClient(timeout=60.0) as client: + async with httpx.AsyncClient(timeout=120.0, follow_redirects=True) as client: async with client.stream("GET", video_url, headers=download_headers) as dl_resp: if dl_resp.status_code == 200: - with open(temp_path, 'wb') as f: + with open(temp_path, "wb") as f: async for chunk in dl_resp.aiter_bytes(chunk_size=8192): f.write(chunk) - logger.info(f"[SuperIPAgent] Downloaded successfully: {temp_path}") + logger.info(f"[douyin-fallback] Downloaded successfully: {temp_path}") return temp_path else: - logger.error(f"[SuperIPAgent] Download failed: {dl_resp.status_code}") + logger.error(f"[douyin-fallback] Download failed: {dl_resp.status_code}") return None except Exception as e: - logger.error(f"[SuperIPAgent] Logic failed: {e}") + logger.error(f"[douyin-fallback] Logic failed: {e}") return None diff --git a/backend/app/modules/videos/schemas.py b/backend/app/modules/videos/schemas.py index 4b2e88f..b7dbca3 100644 --- a/backend/app/modules/videos/schemas.py +++ b/backend/app/modules/videos/schemas.py @@ -26,6 +26,10 @@ class GenerateRequest(BaseModel): enable_subtitles: bool = True subtitle_style_id: Optional[str] = None title_style_id: Optional[str] = None + secondary_title: Optional[str] = None + secondary_title_style_id: Optional[str] = None + secondary_title_font_size: Optional[int] = None + secondary_title_top_margin: Optional[int] = None subtitle_font_size: Optional[int] = None title_font_size: Optional[int] = None title_top_margin: Optional[int] = None diff --git a/backend/app/modules/videos/workflow.py b/backend/app/modules/videos/workflow.py index 5fcd8a2..9e027b1 100644 --- a/backend/app/modules/videos/workflow.py +++ b/backend/app/modules/videos/workflow.py @@ -598,14 +598,17 @@ async def process_video_generation(task_id: str, req: GenerateRequest, user_id: else: 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 title_style = None + secondary_title_style = None if req.enable_subtitles: subtitle_style = get_style("subtitle", req.subtitle_style_id) or get_default_style("subtitle") if req.title: title_style = get_style("title", req.title_style_id) or get_default_style("title") + if req.secondary_title: + secondary_title_style = get_style("title", req.secondary_title_style_id) or get_default_style("title") if req.subtitle_font_size and req.enable_subtitles: if subtitle_style is None: @@ -627,6 +630,16 @@ async def process_video_generation(task_id: str, req: GenerateRequest, user_id: subtitle_style = {} subtitle_style["bottom_margin"] = int(req.subtitle_bottom_margin) + if req.secondary_title_font_size and req.secondary_title: + if secondary_title_style is None: + secondary_title_style = {} + secondary_title_style["font_size"] = int(req.secondary_title_font_size) + + if req.secondary_title_top_margin is not None and req.secondary_title: + if secondary_title_style is None: + secondary_title_style = {} + secondary_title_style["top_margin"] = int(req.secondary_title_top_margin) + if use_remotion: subtitle_style = prepare_style_for_remotion( subtitle_style, @@ -638,6 +651,11 @@ async def process_video_generation(task_id: str, req: GenerateRequest, user_id: temp_dir, f"{task_id}_title_font" ) + secondary_title_style = prepare_style_for_remotion( + secondary_title_style, + temp_dir, + f"{task_id}_secondary_title_font" + ) final_output_local_path = temp_dir / f"{task_id}_output.mp4" 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, subtitle_style=subtitle_style, title_style=title_style, + secondary_title=req.secondary_title, + secondary_title_style=secondary_title_style, on_progress=on_remotion_progress ) print(f"[Pipeline] Remotion render completed") diff --git a/backend/app/services/glm_service.py b/backend/app/services/glm_service.py index 78d7e75..8d9b6f5 100644 --- a/backend/app/services/glm_service.py +++ b/backend/app/services/glm_service.py @@ -35,18 +35,19 @@ class GLMService: Returns: {"title": "标题", "tags": ["标签1", "标签2", ...]} """ - prompt = f"""根据以下口播文案,生成一个吸引人的短视频标题和3个相关标签。 + prompt = f"""根据以下口播文案,生成一个吸引人的短视频标题、副标题和3个相关标签。 口播文案: {text} 要求: 1. 标题要简洁有力,能吸引观众点击,不超过10个字 -2. 标签要与内容相关,便于搜索和推荐,只要3个 -3. 标题和标签必须使用与口播文案相同的语言(如文案是英文就用英文,日文就用日文) +2. 副标题是对标题的补充说明或描述性文字,不超过20个字 +3. 标签要与内容相关,便于搜索和推荐,只要3个 +4. 标题、副标题和标签必须使用与口播文案相同的语言(如文案是英文就用英文,日文就用日文) 请严格按以下JSON格式返回(不要包含其他内容): -{{"title": "标题", "tags": ["标签1", "标签2", "标签3"]}}""" +{{"title": "标题", "secondary_title": "副标题", "tags": ["标签1", "标签2", "标签3"]}}""" try: client = self._get_client() @@ -75,17 +76,24 @@ class GLMService: logger.error(f"GLM service error: {e}") raise Exception(f"AI 生成失败: {str(e)}") - async def rewrite_script(self, text: str) -> str: + async def rewrite_script(self, text: str, custom_prompt: str = None) -> str: """ - AI 洗稿(文案改写) + AI 改写文案 Args: text: 原始文案 + custom_prompt: 自定义提示词,为空则使用默认提示词 Returns: 改写后的文案 """ - prompt = f"""请将以下视频文案进行改写。 + if custom_prompt and custom_prompt.strip(): + prompt = f"""{custom_prompt.strip()} + +原始文案: +{text}""" + else: + prompt = f"""请将以下视频文案进行改写。 原始文案: {text} @@ -174,6 +182,8 @@ class GLMService: # 尝试提取 JSON 块 json_match = re.search(r'\{[^{}]*"title"[^{}]*"tags"[^{}]*\}', content, re.DOTALL) + if not json_match: + json_match = re.search(r'\{[^{}]*"title"[^{}]*"secondary_title"[^{}]*"tags"[^{}]*\}', content, re.DOTALL) if json_match: try: return json.loads(json_match.group()) diff --git a/backend/app/services/remotion_service.py b/backend/app/services/remotion_service.py index 87f3a44..6bcf42b 100644 --- a/backend/app/services/remotion_service.py +++ b/backend/app/services/remotion_service.py @@ -36,6 +36,8 @@ class RemotionService: enable_subtitles: bool = True, subtitle_style: Optional[dict] = None, title_style: Optional[dict] = None, + secondary_title: Optional[str] = None, + secondary_title_style: Optional[dict] = None, on_progress: Optional[Callable[[int], None]] = None ) -> str: """ @@ -86,6 +88,12 @@ class RemotionService: if title_style: cmd.extend(["--titleStyle", json.dumps(title_style, ensure_ascii=False)]) + if secondary_title: + cmd.extend(["--secondaryTitle", secondary_title]) + + if secondary_title_style: + cmd.extend(["--secondaryTitleStyle", json.dumps(secondary_title_style, ensure_ascii=False)]) + logger.info(f"Running Remotion render: {' '.join(cmd)}") # 在线程池中运行子进程 diff --git a/backend/requirements.txt b/backend/requirements.txt index 42b2a7a..af51f4a 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -29,6 +29,9 @@ python-jose[cryptography]>=3.3.0 passlib[bcrypt]>=1.7.4 bcrypt==4.0.1 +# 支付宝支付 +python-alipay-sdk>=3.6.0 + # 字幕对齐 faster-whisper>=1.0.0 diff --git a/frontend/src/features/home/model/useHomeController.ts b/frontend/src/features/home/model/useHomeController.ts index 702c832..e7419e2 100644 --- a/frontend/src/features/home/model/useHomeController.ts +++ b/frontend/src/features/home/model/useHomeController.ts @@ -9,7 +9,7 @@ import { resolveBgmUrl, resolveMediaUrl, } from "@/shared/lib/media"; -import { clampTitle } from "@/shared/lib/title"; +import { clampTitle, clampSecondaryTitle, SECONDARY_TITLE_MAX_LENGTH } from "@/shared/lib/title"; import { useTitleInput } from "@/shared/hooks/useTitleInput"; import { useAuth } from "@/shared/contexts/AuthContext"; import { useTask } from "@/shared/contexts/TaskContext"; @@ -157,6 +157,13 @@ export const useHomeController = () => { const [showStylePreview, setShowStylePreview] = useState<boolean>(false); const [materialDimensions, setMaterialDimensions] = useState<{ width: number; height: number } | null>(null); + // 副标题相关状态 + const [videoSecondaryTitle, setVideoSecondaryTitle] = useState<string>(""); + const [selectedSecondaryTitleStyleId, setSelectedSecondaryTitleStyleId] = useState<string>(""); + const [secondaryTitleFontSize, setSecondaryTitleFontSize] = useState<number>(48); + const [secondaryTitleTopMargin, setSecondaryTitleTopMargin] = useState<number>(12); + const [secondaryTitleSizeLocked, setSecondaryTitleSizeLocked] = useState<boolean>(false); + // 背景音乐相关状态 const [selectedBgmId, setSelectedBgmId] = useState<string>(""); @@ -430,6 +437,8 @@ export const useHomeController = () => { setText, videoTitle, setVideoTitle, + videoSecondaryTitle, + setVideoSecondaryTitle, ttsMode, setTtsMode, voice, @@ -442,14 +451,21 @@ export const useHomeController = () => { setSelectedSubtitleStyleId, selectedTitleStyleId, setSelectedTitleStyleId, + selectedSecondaryTitleStyleId, + setSelectedSecondaryTitleStyleId, subtitleFontSize, setSubtitleFontSize, titleFontSize, setTitleFontSize, + secondaryTitleFontSize, + setSecondaryTitleFontSize, setSubtitleSizeLocked, setTitleSizeLocked, + setSecondaryTitleSizeLocked, titleTopMargin, setTitleTopMargin, + secondaryTitleTopMargin, + setSecondaryTitleTopMargin, titleDisplayMode, setTitleDisplayMode, subtitleBottomMargin, @@ -491,6 +507,12 @@ export const useHomeController = () => { onCommit: syncTitleToPublish, }); + const secondaryTitleInput = useTitleInput({ + value: videoSecondaryTitle, + onChange: setVideoSecondaryTitle, + maxLength: SECONDARY_TITLE_MAX_LENGTH, + }); + // 加载素材列表和历史视频 useEffect(() => { if (isAuthLoading) return; @@ -582,6 +604,16 @@ export const useHomeController = () => { } }, [titleStyles, selectedTitleStyleId, titleSizeLocked]); + useEffect(() => { + if (secondaryTitleSizeLocked || titleStyles.length === 0) return; + const active = titleStyles.find((s) => s.id === selectedSecondaryTitleStyleId) + || titleStyles.find((s) => s.is_default) + || titleStyles[0]; + if (active?.font_size) { + setSecondaryTitleFontSize(active.font_size); + } + }, [titleStyles, selectedSecondaryTitleStyleId, secondaryTitleSizeLocked]); + // 移除重复的 BGM 持久化恢复逻辑 (已统一移动到 useHomePersistence 中) // useEffect(() => { ... }) @@ -741,7 +773,7 @@ export const useHomeController = () => { setIsGeneratingMeta(true); try { - const { data: res } = await api.post<ApiResponse<{ title?: string; tags?: string[] }>>( + const { data: res } = await api.post<ApiResponse<{ title?: string; secondary_title?: string; tags?: string[] }>>( "/api/ai/generate-meta", { text: text.trim() } ); @@ -751,6 +783,10 @@ export const useHomeController = () => { const nextTitle = clampTitle(payload.title || ""); titleInput.commitValue(nextTitle); + // 更新副标题 + const nextSecondaryTitle = clampSecondaryTitle(payload.secondary_title || ""); + secondaryTitleInput.commitValue(nextSecondaryTitle); + // 同步到发布页 localStorage localStorage.setItem(`vigent_${storageKey}_publish_tags`, JSON.stringify(payload.tags || [])); } catch (err: unknown) { @@ -950,6 +986,17 @@ export const useHomeController = () => { payload.title_top_margin = Math.round(titleTopMargin); } + if (videoSecondaryTitle.trim()) { + payload.secondary_title = videoSecondaryTitle.trim(); + if (selectedSecondaryTitleStyleId) { + payload.secondary_title_style_id = selectedSecondaryTitleStyleId; + } + if (secondaryTitleFontSize) { + payload.secondary_title_font_size = Math.round(secondaryTitleFontSize); + } + payload.secondary_title_top_margin = Math.round(secondaryTitleTopMargin); + } + payload.subtitle_bottom_margin = Math.round(subtitleBottomMargin); if (enableBgm && selectedBgmId) { @@ -1049,6 +1096,15 @@ export const useHomeController = () => { titleFontSize, setTitleFontSize, setTitleSizeLocked, + videoSecondaryTitle, + secondaryTitleInput, + selectedSecondaryTitleStyleId, + setSelectedSecondaryTitleStyleId, + secondaryTitleFontSize, + setSecondaryTitleFontSize, + setSecondaryTitleSizeLocked, + secondaryTitleTopMargin, + setSecondaryTitleTopMargin, subtitleStyles, selectedSubtitleStyleId, setSelectedSubtitleStyleId, diff --git a/frontend/src/features/home/model/useHomePersistence.ts b/frontend/src/features/home/model/useHomePersistence.ts index 25ec2f2..bf59596 100644 --- a/frontend/src/features/home/model/useHomePersistence.ts +++ b/frontend/src/features/home/model/useHomePersistence.ts @@ -1,5 +1,5 @@ import { useEffect, useState } from "react"; -import { clampTitle } from "@/shared/lib/title"; +import { clampTitle, clampSecondaryTitle } from "@/shared/lib/title"; interface RefAudio { id: string; @@ -17,6 +17,8 @@ interface UseHomePersistenceOptions { setText: React.Dispatch<React.SetStateAction<string>>; videoTitle: string; setVideoTitle: React.Dispatch<React.SetStateAction<string>>; + videoSecondaryTitle: string; + setVideoSecondaryTitle: React.Dispatch<React.SetStateAction<string>>; ttsMode: 'edgetts' | 'voiceclone'; setTtsMode: React.Dispatch<React.SetStateAction<'edgetts' | 'voiceclone'>>; voice: string; @@ -29,14 +31,21 @@ interface UseHomePersistenceOptions { setSelectedSubtitleStyleId: React.Dispatch<React.SetStateAction<string>>; selectedTitleStyleId: string; setSelectedTitleStyleId: React.Dispatch<React.SetStateAction<string>>; + selectedSecondaryTitleStyleId: string; + setSelectedSecondaryTitleStyleId: React.Dispatch<React.SetStateAction<string>>; subtitleFontSize: number; setSubtitleFontSize: React.Dispatch<React.SetStateAction<number>>; titleFontSize: number; setTitleFontSize: React.Dispatch<React.SetStateAction<number>>; + secondaryTitleFontSize: number; + setSecondaryTitleFontSize: React.Dispatch<React.SetStateAction<number>>; setSubtitleSizeLocked: React.Dispatch<React.SetStateAction<boolean>>; setTitleSizeLocked: React.Dispatch<React.SetStateAction<boolean>>; + setSecondaryTitleSizeLocked: React.Dispatch<React.SetStateAction<boolean>>; titleTopMargin: number; setTitleTopMargin: React.Dispatch<React.SetStateAction<number>>; + secondaryTitleTopMargin: number; + setSecondaryTitleTopMargin: React.Dispatch<React.SetStateAction<number>>; titleDisplayMode: 'short' | 'persistent'; setTitleDisplayMode: React.Dispatch<React.SetStateAction<'short' | 'persistent'>>; subtitleBottomMargin: number; @@ -65,6 +74,8 @@ export const useHomePersistence = ({ setText, videoTitle, setVideoTitle, + videoSecondaryTitle, + setVideoSecondaryTitle, ttsMode, setTtsMode, voice, @@ -77,14 +88,21 @@ export const useHomePersistence = ({ setSelectedSubtitleStyleId, selectedTitleStyleId, setSelectedTitleStyleId, + selectedSecondaryTitleStyleId, + setSelectedSecondaryTitleStyleId, subtitleFontSize, setSubtitleFontSize, titleFontSize, setTitleFontSize, + secondaryTitleFontSize, + setSecondaryTitleFontSize, setSubtitleSizeLocked, setTitleSizeLocked, + setSecondaryTitleSizeLocked, titleTopMargin, setTitleTopMargin, + secondaryTitleTopMargin, + setSecondaryTitleTopMargin, titleDisplayMode, setTitleDisplayMode, subtitleBottomMargin, @@ -112,20 +130,24 @@ export const useHomePersistence = ({ const savedText = localStorage.getItem(`vigent_${storageKey}_text`); const savedTitle = localStorage.getItem(`vigent_${storageKey}_title`); + const savedSecondaryTitle = localStorage.getItem(`vigent_${storageKey}_secondaryTitle`); const savedTtsMode = localStorage.getItem(`vigent_${storageKey}_ttsMode`); const savedVoice = localStorage.getItem(`vigent_${storageKey}_voice`); const savedTextLang = localStorage.getItem(`vigent_${storageKey}_textLang`); const savedMaterial = localStorage.getItem(`vigent_${storageKey}_material`); const savedSubtitleStyle = localStorage.getItem(`vigent_${storageKey}_subtitleStyle`); const savedTitleStyle = localStorage.getItem(`vigent_${storageKey}_titleStyle`); + const savedSecondaryTitleStyle = localStorage.getItem(`vigent_${storageKey}_secondaryTitleStyle`); const savedSubtitleFontSize = localStorage.getItem(`vigent_${storageKey}_subtitleFontSize`); const savedTitleFontSize = localStorage.getItem(`vigent_${storageKey}_titleFontSize`); + const savedSecondaryTitleFontSize = localStorage.getItem(`vigent_${storageKey}_secondaryTitleFontSize`); const savedBgmId = localStorage.getItem(`vigent_${storageKey}_bgmId`); const savedSelectedVideoId = localStorage.getItem(`vigent_${storageKey}_selectedVideoId`); const savedSelectedAudioId = localStorage.getItem(`vigent_${storageKey}_selectedAudioId`); const savedBgmVolume = localStorage.getItem(`vigent_${storageKey}_bgmVolume`); const savedEnableBgm = localStorage.getItem(`vigent_${storageKey}_enableBgm`); const savedTitleTopMargin = localStorage.getItem(`vigent_${storageKey}_titleTopMargin`); + const savedSecondaryTitleTopMargin = localStorage.getItem(`vigent_${storageKey}_secondaryTitleTopMargin`); const savedTitleDisplayMode = localStorage.getItem(`vigent_${storageKey}_titleDisplayMode`); const savedSubtitleBottomMargin = localStorage.getItem(`vigent_${storageKey}_subtitleBottomMargin`); const savedOutputAspectRatio = localStorage.getItem(`vigent_${storageKey}_outputAspectRatio`); @@ -133,6 +155,7 @@ export const useHomePersistence = ({ setText(savedText || "大家好,欢迎来到我的频道,今天给大家分享一些有趣的内容。"); setVideoTitle(savedTitle ? clampTitle(savedTitle) : ""); + setVideoSecondaryTitle(savedSecondaryTitle ? clampSecondaryTitle(savedSecondaryTitle) : ""); setTtsMode((savedTtsMode as 'edgetts' | 'voiceclone') || 'edgetts'); setVoice(savedVoice || "zh-CN-YunxiNeural"); if (savedTextLang) setTextLang(savedTextLang); @@ -152,6 +175,7 @@ export const useHomePersistence = ({ } if (savedSubtitleStyle) setSelectedSubtitleStyleId(savedSubtitleStyle); if (savedTitleStyle) setSelectedTitleStyleId(savedTitleStyle); + if (savedSecondaryTitleStyle) setSelectedSecondaryTitleStyleId(savedSecondaryTitleStyle); if (savedSubtitleFontSize) { 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 (savedBgmVolume) setBgmVolume(parseFloat(savedBgmVolume)); if (savedEnableBgm !== null) setEnableBgm(savedEnableBgm === 'true'); @@ -179,6 +211,10 @@ export const useHomePersistence = ({ const parsed = parseInt(savedTitleTopMargin, 10); if (!Number.isNaN(parsed)) setTitleTopMargin(parsed); } + if (savedSecondaryTitleTopMargin) { + const parsed = parseInt(savedSecondaryTitleTopMargin, 10); + if (!Number.isNaN(parsed)) setSecondaryTitleTopMargin(parsed); + } if (savedTitleDisplayMode === 'short' || savedTitleDisplayMode === 'persistent') { setTitleDisplayMode(savedTitleDisplayMode); } @@ -206,6 +242,7 @@ export const useHomePersistence = ({ setSelectedMaterials, setSelectedSubtitleStyleId, setSelectedTitleStyleId, + setSelectedSecondaryTitleStyleId, setSelectedVideoId, setSelectedAudioId, setSpeed, @@ -215,12 +252,16 @@ export const useHomePersistence = ({ setTextLang, setTitleFontSize, setTitleSizeLocked, + setSecondaryTitleFontSize, + setSecondaryTitleSizeLocked, setTitleTopMargin, + setSecondaryTitleTopMargin, setTitleDisplayMode, setSubtitleBottomMargin, setOutputAspectRatio, setTtsMode, setVideoTitle, + setVideoSecondaryTitle, setVoice, storageKey, ]); @@ -241,6 +282,14 @@ export const useHomePersistence = ({ return () => clearTimeout(timeout); }, [videoTitle, storageKey, isRestored]); + useEffect(() => { + if (!isRestored) return; + const timeout = setTimeout(() => { + localStorage.setItem(`vigent_${storageKey}_secondaryTitle`, videoSecondaryTitle); + }, 300); + return () => clearTimeout(timeout); + }, [videoSecondaryTitle, storageKey, isRestored]); + useEffect(() => { if (isRestored) localStorage.setItem(`vigent_${storageKey}_ttsMode`, ttsMode); }, [ttsMode, storageKey, isRestored]); @@ -271,6 +320,12 @@ export const useHomePersistence = ({ } }, [selectedTitleStyleId, storageKey, isRestored]); + useEffect(() => { + if (isRestored && selectedSecondaryTitleStyleId) { + localStorage.setItem(`vigent_${storageKey}_secondaryTitleStyle`, selectedSecondaryTitleStyleId); + } + }, [selectedSecondaryTitleStyleId, storageKey, isRestored]); + useEffect(() => { if (isRestored) { localStorage.setItem(`vigent_${storageKey}_subtitleFontSize`, String(subtitleFontSize)); @@ -283,12 +338,24 @@ export const useHomePersistence = ({ } }, [titleFontSize, storageKey, isRestored]); + useEffect(() => { + if (isRestored) { + localStorage.setItem(`vigent_${storageKey}_secondaryTitleFontSize`, String(secondaryTitleFontSize)); + } + }, [secondaryTitleFontSize, storageKey, isRestored]); + useEffect(() => { if (isRestored) { localStorage.setItem(`vigent_${storageKey}_titleTopMargin`, String(titleTopMargin)); } }, [titleTopMargin, storageKey, isRestored]); + useEffect(() => { + if (isRestored) { + localStorage.setItem(`vigent_${storageKey}_secondaryTitleTopMargin`, String(secondaryTitleTopMargin)); + } + }, [secondaryTitleTopMargin, storageKey, isRestored]); + useEffect(() => { if (isRestored) { localStorage.setItem(`vigent_${storageKey}_titleDisplayMode`, titleDisplayMode); diff --git a/frontend/src/features/home/ui/FloatingStylePreview.tsx b/frontend/src/features/home/ui/FloatingStylePreview.tsx index 40fcf54..876fe0a 100644 --- a/frontend/src/features/home/ui/FloatingStylePreview.tsx +++ b/frontend/src/features/home/ui/FloatingStylePreview.tsx @@ -35,9 +35,13 @@ interface TitleStyleOption { interface FloatingStylePreviewProps { onClose: () => void; videoTitle: string; + videoSecondaryTitle: string; titleStyles: TitleStyleOption[]; selectedTitleStyleId: string; titleFontSize: number; + selectedSecondaryTitleStyleId: string; + secondaryTitleFontSize: number; + secondaryTitleTopMargin: number; subtitleStyles: SubtitleStyleOption[]; selectedSubtitleStyleId: string; subtitleFontSize: number; @@ -56,9 +60,13 @@ const DESKTOP_WIDTH = 280; export function FloatingStylePreview({ onClose, videoTitle, + videoSecondaryTitle, titleStyles, selectedTitleStyleId, titleFontSize, + selectedSecondaryTitleStyleId, + secondaryTitleFontSize, + secondaryTitleTopMargin, subtitleStyles, selectedSubtitleStyleId, subtitleFontSize, @@ -126,6 +134,22 @@ export function FloatingStylePreview({ const scaledTitleTopMargin = Math.max(0, Math.round(titleTopMargin * responsiveScale)); const scaledSubtitleBottomMargin = Math.max(0, Math.round(subtitleBottomMargin * responsiveScale)); + // 副标题样式 + const activeSecondaryTitleStyle = titleStyles.find((s) => s.id === selectedSecondaryTitleStyleId) + || activeTitleStyle; + const stColor = activeSecondaryTitleStyle?.color || "#FFFFFF"; + const stStrokeColor = activeSecondaryTitleStyle?.stroke_color || "#000000"; + const stStrokeSize = Math.max(1, Math.round((activeSecondaryTitleStyle?.stroke_size ?? 6) * responsiveScale)); + const stLetterSpacing = Math.max(0, (activeSecondaryTitleStyle?.letter_spacing ?? 2) * responsiveScale); + const stFontWeight = activeSecondaryTitleStyle?.font_weight ?? 700; + const stFontFamilyName = `SecondaryTitlePreview-${activeSecondaryTitleStyle?.id || "default"}`; + const stFontUrl = activeSecondaryTitleStyle?.font_file + ? resolveAssetUrl(`fonts/${activeSecondaryTitleStyle.font_file}`) + : null; + const scaledSecondaryTitleFontSize = Math.max(24, Math.round(secondaryTitleFontSize * responsiveScale)); + const scaledSecondaryTitleTopMargin = Math.max(0, Math.round(secondaryTitleTopMargin * responsiveScale)); + const previewSecondaryTitleText = videoSecondaryTitle.trim() || ""; + const content = ( <div style={{ @@ -159,9 +183,10 @@ export function FloatingStylePreview({ className="relative overflow-hidden rounded-b-xl" style={{ height: `${previewHeight}px` }} > - {(titleFontUrl || subtitleFontUrl) && ( + {(titleFontUrl || subtitleFontUrl || stFontUrl) && ( <style>{` ${titleFontUrl ? `@font-face { font-family: '${titleFontFamilyName}'; src: url('${titleFontUrl}') format('${getFontFormat(activeTitleStyle?.font_file)}'); font-weight: 400; font-style: normal; }` : ''} + ${stFontUrl && stFontUrl !== titleFontUrl ? `@font-face { font-family: '${stFontFamilyName}'; src: url('${stFontUrl}') format('${getFontFormat(activeSecondaryTitleStyle?.font_file)}'); font-weight: 400; font-style: normal; }` : ''} ${subtitleFontUrl ? `@font-face { font-family: '${subtitleFontFamilyName}'; src: url('${subtitleFontUrl}') format('${getFontFormat(activeSubtitleStyle?.font_file)}'); font-weight: 400; font-style: normal; }` : ''} `}</style> )} @@ -182,24 +207,55 @@ export function FloatingStylePreview({ top: `${scaledTitleTopMargin}px`, left: 0, right: 0, - color: titleColor, - fontSize: `${scaledTitleFontSize}px`, - fontWeight: titleFontWeight, - fontFamily: titleFontUrl - ? `'${titleFontFamilyName}', "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans SC", sans-serif` - : '"PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans SC", sans-serif', - textShadow: buildTextShadow(titleStrokeColor, titleStrokeSize), - letterSpacing: `${titleLetterSpacing}px`, - lineHeight: 1.2, - whiteSpace: 'normal', - wordBreak: 'break-word', - overflowWrap: 'anywhere', - boxSizing: 'border-box', - opacity: videoTitle.trim() ? 1 : 0.7, + display: 'flex', + flexDirection: 'column', + alignItems: 'center', padding: '0 5%', + boxSizing: 'border-box', }} > - {previewTitleText} + <div + style={{ + color: titleColor, + fontSize: `${scaledTitleFontSize}px`, + fontWeight: titleFontWeight, + fontFamily: titleFontUrl + ? `'${titleFontFamilyName}', "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans SC", sans-serif` + : '"PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans SC", sans-serif', + textShadow: buildTextShadow(titleStrokeColor, titleStrokeSize), + letterSpacing: `${titleLetterSpacing}px`, + lineHeight: 1.2, + whiteSpace: 'normal', + wordBreak: 'break-word', + overflowWrap: 'anywhere', + opacity: videoTitle.trim() ? 1 : 0.7, + }} + > + {previewTitleText} + </div> + {previewSecondaryTitleText && ( + <div + style={{ + marginTop: `${scaledSecondaryTitleTopMargin}px`, + color: stColor, + fontSize: `${scaledSecondaryTitleFontSize}px`, + fontWeight: stFontWeight, + fontFamily: stFontUrl && stFontUrl !== titleFontUrl + ? `'${stFontFamilyName}', "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans SC", sans-serif` + : titleFontUrl + ? `'${titleFontFamilyName}', "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans SC", sans-serif` + : '"PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans SC", sans-serif', + textShadow: buildTextShadow(stStrokeColor, stStrokeSize), + letterSpacing: `${stLetterSpacing}px`, + lineHeight: 1.2, + whiteSpace: 'normal', + wordBreak: 'break-word', + overflowWrap: 'anywhere', + }} + > + {previewSecondaryTitleText} + </div> + )} </div> <div diff --git a/frontend/src/features/home/ui/HomePage.tsx b/frontend/src/features/home/ui/HomePage.tsx index 709220e..17cc989 100644 --- a/frontend/src/features/home/ui/HomePage.tsx +++ b/frontend/src/features/home/ui/HomePage.tsx @@ -70,6 +70,15 @@ export function HomePage() { titleFontSize, setTitleFontSize, setTitleSizeLocked, + videoSecondaryTitle, + secondaryTitleInput, + selectedSecondaryTitleStyleId, + setSelectedSecondaryTitleStyleId, + secondaryTitleFontSize, + setSecondaryTitleFontSize, + setSecondaryTitleSizeLocked, + secondaryTitleTopMargin, + setSecondaryTitleTopMargin, subtitleStyles, selectedSubtitleStyleId, setSelectedSubtitleStyleId, @@ -217,6 +226,10 @@ export function HomePage() { onTitleChange={titleInput.handleChange} onTitleCompositionStart={titleInput.handleCompositionStart} onTitleCompositionEnd={titleInput.handleCompositionEnd} + videoSecondaryTitle={videoSecondaryTitle} + onSecondaryTitleChange={secondaryTitleInput.handleChange} + onSecondaryTitleCompositionStart={secondaryTitleInput.handleCompositionStart} + onSecondaryTitleCompositionEnd={secondaryTitleInput.handleCompositionEnd} titleStyles={titleStyles} selectedTitleStyleId={selectedTitleStyleId} onSelectTitleStyle={setSelectedTitleStyleId} @@ -225,6 +238,15 @@ export function HomePage() { setTitleFontSize(value); setTitleSizeLocked(true); }} + selectedSecondaryTitleStyleId={selectedSecondaryTitleStyleId} + onSelectSecondaryTitleStyle={setSelectedSecondaryTitleStyleId} + secondaryTitleFontSize={secondaryTitleFontSize} + onSecondaryTitleFontSizeChange={(value) => { + setSecondaryTitleFontSize(value); + setSecondaryTitleSizeLocked(true); + }} + secondaryTitleTopMargin={secondaryTitleTopMargin} + onSecondaryTitleTopMarginChange={setSecondaryTitleTopMargin} subtitleStyles={subtitleStyles} selectedSubtitleStyleId={selectedSubtitleStyleId} onSelectSubtitleStyle={setSelectedSubtitleStyleId} diff --git a/frontend/src/features/home/ui/ScriptExtractionModal.tsx b/frontend/src/features/home/ui/ScriptExtractionModal.tsx index 5545355..9daa417 100644 --- a/frontend/src/features/home/ui/ScriptExtractionModal.tsx +++ b/frontend/src/features/home/ui/ScriptExtractionModal.tsx @@ -26,9 +26,13 @@ export default function ScriptExtractionModal({ selectedFile, activeTab, inputUrl, + customPrompt, + showCustomPrompt, setDoRewrite, setActiveTab, setInputUrl, + setCustomPrompt, + setShowCustomPrompt, handleDrag, handleDrop, handleFileChange, @@ -187,18 +191,43 @@ export default function ScriptExtractionModal({ )} {/* Options */} - <div className="flex items-center gap-3 bg-white/5 rounded-xl p-4 border border-white/10"> - <label className="flex items-center gap-2 cursor-pointer"> - <input - type="checkbox" - checked={doRewrite} - onChange={(e) => setDoRewrite(e.target.checked)} - className="w-4 h-4 rounded bg-white/10 border-white/20 text-purple-500 focus:ring-purple-500" - /> - <span className="text-sm text-gray-300"> - AI 智能改写(去口语化) - </span> - </label> + <div className="bg-white/5 rounded-xl border border-white/10 overflow-hidden"> + <div className="flex items-center justify-between p-4"> + <label className="flex items-center gap-2 cursor-pointer"> + <input + type="checkbox" + checked={doRewrite} + onChange={(e) => setDoRewrite(e.target.checked)} + className="w-4 h-4 rounded bg-white/10 border-white/20 text-purple-500 focus:ring-purple-500" + /> + <span className="text-sm text-gray-300"> + AI 智能改写 + </span> + </label> + {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> {/* Error */} @@ -261,7 +290,7 @@ export default function ScriptExtractionModal({ <div className="space-y-2"> <div className="flex justify-between items-center"> <h4 className="font-semibold text-purple-300 flex items-center gap-2"> - ✨ AI 洗稿结果{" "} + ✨ AI 改写结果{" "} <span className="text-xs font-normal text-purple-400/70"> (推荐) </span> diff --git a/frontend/src/features/home/ui/TitleSubtitlePanel.tsx b/frontend/src/features/home/ui/TitleSubtitlePanel.tsx index 45b9a41..7264470 100644 --- a/frontend/src/features/home/ui/TitleSubtitlePanel.tsx +++ b/frontend/src/features/home/ui/TitleSubtitlePanel.tsx @@ -38,11 +38,21 @@ interface TitleSubtitlePanelProps { onTitleChange: (value: string) => void; onTitleCompositionStart?: () => void; onTitleCompositionEnd?: (value: string) => void; + videoSecondaryTitle: string; + onSecondaryTitleChange: (value: string) => void; + onSecondaryTitleCompositionStart?: () => void; + onSecondaryTitleCompositionEnd?: (value: string) => void; titleStyles: TitleStyleOption[]; selectedTitleStyleId: string; onSelectTitleStyle: (id: string) => void; titleFontSize: number; onTitleFontSizeChange: (value: number) => void; + selectedSecondaryTitleStyleId: string; + onSelectSecondaryTitleStyle: (id: string) => void; + secondaryTitleFontSize: number; + onSecondaryTitleFontSizeChange: (value: number) => void; + secondaryTitleTopMargin: number; + onSecondaryTitleTopMarginChange: (value: number) => void; subtitleStyles: SubtitleStyleOption[]; selectedSubtitleStyleId: string; onSelectSubtitleStyle: (id: string) => void; @@ -68,11 +78,21 @@ export function TitleSubtitlePanel({ onTitleChange, onTitleCompositionStart, onTitleCompositionEnd, + videoSecondaryTitle, + onSecondaryTitleChange, + onSecondaryTitleCompositionStart, + onSecondaryTitleCompositionEnd, titleStyles, selectedTitleStyleId, onSelectTitleStyle, titleFontSize, onTitleFontSizeChange, + selectedSecondaryTitleStyleId, + onSelectSecondaryTitleStyle, + secondaryTitleFontSize, + onSecondaryTitleFontSizeChange, + secondaryTitleTopMargin, + onSecondaryTitleTopMarginChange, subtitleStyles, selectedSubtitleStyleId, onSelectSubtitleStyle, @@ -109,9 +129,13 @@ export function TitleSubtitlePanel({ <FloatingStylePreview onClose={onTogglePreview} videoTitle={videoTitle} + videoSecondaryTitle={videoSecondaryTitle} titleStyles={titleStyles} selectedTitleStyleId={selectedTitleStyleId} titleFontSize={titleFontSize} + selectedSecondaryTitleStyleId={selectedSecondaryTitleStyleId} + secondaryTitleFontSize={secondaryTitleFontSize} + secondaryTitleTopMargin={secondaryTitleTopMargin} subtitleStyles={subtitleStyles} selectedSubtitleStyleId={selectedSubtitleStyleId} subtitleFontSize={subtitleFontSize} @@ -153,6 +177,19 @@ export function TitleSubtitlePanel({ /> </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 && ( <div className="mb-4"> <label className="text-sm text-gray-300 mb-2 block">标题样式</label> @@ -200,6 +237,53 @@ export function TitleSubtitlePanel({ </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 && ( <div className="mt-4"> <label className="text-sm text-gray-300 mb-2 block">字幕样式</label> diff --git a/frontend/src/features/home/ui/script-extraction/useScriptExtraction.ts b/frontend/src/features/home/ui/script-extraction/useScriptExtraction.ts index 4b2b5f9..814309d 100644 --- a/frontend/src/features/home/ui/script-extraction/useScriptExtraction.ts +++ b/frontend/src/features/home/ui/script-extraction/useScriptExtraction.ts @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback } from "react"; +import { useState, useEffect, useCallback, useRef } from "react"; import api from "@/shared/api/axios"; import { ApiResponse, unwrap } from "@/shared/api/types"; import { toast } from "sonner"; @@ -7,6 +7,7 @@ export type ExtractionStep = "config" | "processing" | "result"; export type InputTab = "file" | "url"; const VALID_FILE_TYPES = [".mp4", ".mov", ".avi", ".mp3", ".wav", ".m4a"]; +const CUSTOM_PROMPT_KEY = "vigent_rewriteCustomPrompt"; interface UseScriptExtractionOptions { isOpen: boolean; @@ -23,8 +24,19 @@ export const useScriptExtraction = ({ isOpen }: UseScriptExtractionOptions) => { const [selectedFile, setSelectedFile] = useState<File | null>(null); const [activeTab, setActiveTab] = useState<InputTab>("url"); 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(() => { if (isOpen) { setStep("config"); @@ -101,6 +113,9 @@ export const useScriptExtraction = ({ isOpen }: UseScriptExtractionOptions) => { formData.append("url", inputUrl.trim()); } formData.append("rewrite", doRewrite ? "true" : "false"); + if (doRewrite && customPrompt.trim()) { + formData.append("custom_prompt", customPrompt.trim()); + } const { data: res } = await api.post< ApiResponse<{ original_script: string; rewritten_script?: string }> @@ -126,7 +141,7 @@ export const useScriptExtraction = ({ isOpen }: UseScriptExtractionOptions) => { } finally { setIsLoading(false); } - }, [activeTab, selectedFile, inputUrl, doRewrite]); + }, [activeTab, selectedFile, inputUrl, doRewrite, customPrompt]); const copyToClipboard = useCallback((text: string) => { if (navigator.clipboard && window.isSecureContext) { @@ -193,10 +208,14 @@ export const useScriptExtraction = ({ isOpen }: UseScriptExtractionOptions) => { selectedFile, activeTab, inputUrl, + customPrompt, + showCustomPrompt, // Setters setDoRewrite, setActiveTab, setInputUrl, + setCustomPrompt, + setShowCustomPrompt, // Handlers handleDrag, handleDrop, diff --git a/frontend/src/shared/lib/title.ts b/frontend/src/shared/lib/title.ts index 2801d14..7f94c8c 100644 --- a/frontend/src/shared/lib/title.ts +++ b/frontend/src/shared/lib/title.ts @@ -1,8 +1,12 @@ export const TITLE_MAX_LENGTH = 15; +export const SECONDARY_TITLE_MAX_LENGTH = 20; export const clampTitle = (value: string, maxLength: number = TITLE_MAX_LENGTH) => value.slice(0, maxLength); +export const clampSecondaryTitle = (value: string, maxLength: number = SECONDARY_TITLE_MAX_LENGTH) => + value.slice(0, maxLength); + export const applyTitleLimit = ( prev: string, next: string, diff --git a/remotion/render.ts b/remotion/render.ts index 4b007d0..18459ba 100644 --- a/remotion/render.ts +++ b/remotion/render.ts @@ -19,6 +19,8 @@ interface RenderOptions { titleDisplayMode?: 'short' | 'persistent'; subtitleStyle?: Record<string, unknown>; titleStyle?: Record<string, unknown>; + secondaryTitle?: string; + secondaryTitleStyle?: Record<string, unknown>; outputPath: string; fps?: number; enableSubtitles?: boolean; @@ -75,6 +77,16 @@ async function parseArgs(): Promise<RenderOptions> { console.warn('Invalid titleStyle JSON'); } 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', subtitleStyle: options.subtitleStyle, titleStyle: options.titleStyle, + secondaryTitle: options.secondaryTitle, + secondaryTitleStyle: options.secondaryTitleStyle, enableSubtitles: options.enableSubtitles !== false, width: videoWidth, height: videoHeight, diff --git a/remotion/src/Root.tsx b/remotion/src/Root.tsx index ff7b24f..91481fb 100644 --- a/remotion/src/Root.tsx +++ b/remotion/src/Root.tsx @@ -25,9 +25,11 @@ export const RemotionRoot: React.FC = () => { audioSrc: undefined, captions: undefined, title: undefined, + secondaryTitle: undefined, titleDuration: 4, titleDisplayMode: 'short', enableSubtitles: true, + secondaryTitleStyle: undefined, width: 1080, height: 1920, }} diff --git a/remotion/src/Video.tsx b/remotion/src/Video.tsx index ebe55a6..d2cdb55 100644 --- a/remotion/src/Video.tsx +++ b/remotion/src/Video.tsx @@ -10,11 +10,13 @@ export interface VideoProps { audioSrc?: string; captions?: CaptionsData; title?: string; + secondaryTitle?: string; titleDuration?: number; titleDisplayMode?: 'short' | 'persistent'; enableSubtitles?: boolean; subtitleStyle?: SubtitleStyle; titleStyle?: TitleStyle; + secondaryTitleStyle?: TitleStyle; width?: number; height?: number; } @@ -28,11 +30,13 @@ export const Video: React.FC<VideoProps> = ({ audioSrc, captions, title, + secondaryTitle, titleDuration = 4, titleDisplayMode = 'short', enableSubtitles = true, subtitleStyle, titleStyle, + secondaryTitleStyle, }) => { return ( <AbsoluteFill style={{ backgroundColor: 'black' }}> @@ -45,8 +49,15 @@ export const Video: React.FC<VideoProps> = ({ )} {/* 顶层:标题 */} - {title && ( - <Title title={title} duration={titleDuration} displayMode={titleDisplayMode} style={titleStyle} /> + {(title || secondaryTitle) && ( + <Title + title={title || ''} + secondaryTitle={secondaryTitle} + duration={titleDuration} + displayMode={titleDisplayMode} + style={titleStyle} + secondaryTitleStyle={secondaryTitleStyle} + /> )} </AbsoluteFill> ); diff --git a/remotion/src/components/Title.tsx b/remotion/src/components/Title.tsx index d27ba09..5e3474a 100644 --- a/remotion/src/components/Title.tsx +++ b/remotion/src/components/Title.tsx @@ -25,10 +25,12 @@ export interface TitleStyle { interface TitleProps { title: string; + secondaryTitle?: string; duration?: number; // 标题显示时长(秒) displayMode?: 'short' | 'persistent'; // 短暂显示或常驻显示 fadeOutStart?: number; // 开始淡出的时间(秒) 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}`, - `0 0 ${size * 4}px rgba(0,0,0,0.9)`, - `0 4px 8px rgba(0,0,0,0.6)` + `0 0 ${size * 2}px rgba(0,0,0,0.5)`, + `0 2px 4px rgba(0,0,0,0.3)` ].join(','); }; export const Title: React.FC<TitleProps> = ({ title, + secondaryTitle, duration = 4, displayMode = 'short', fadeOutStart, style, + secondaryTitleStyle, }) => { const frame = useCurrentFrame(); const { fps, width } = useVideoConfig(); @@ -130,9 +134,32 @@ export const Title: React.FC<TitleProps> = ({ ? `'${fontFamilyName}'` : '"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 ( <AbsoluteFill style={{ + flexDirection: 'column', justifyContent: 'flex-start', alignItems: 'center', paddingTop: typeof topMargin === 'number' ? `${topMargin}px` : '6%', @@ -149,6 +176,16 @@ export const Title: React.FC<TitleProps> = ({ } `}</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 style={{ transform: `translateY(${translateY}px)`, @@ -171,6 +208,31 @@ export const Title: React.FC<TitleProps> = ({ > {title} </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> ); };