Compare commits

...

3 Commits

Author SHA1 Message Date
Kevin Wong
42b5cc0c02 更新 2026-02-26 10:14:41 +08:00
Kevin Wong
1717635bfd 更新 2026-02-25 17:51:58 +08:00
Kevin Wong
0a5a17402c 更新 2026-02-24 16:55:29 +08:00
54 changed files with 2299 additions and 793 deletions

1
.gitignore vendored
View File

@@ -40,6 +40,7 @@ backend/uploads/
backend/cookies/ backend/cookies/
backend/user_data/ backend/user_data/
backend/debug_screenshots/ backend/debug_screenshots/
backend/keys/
*_cookies.json *_cookies.json
# ============ 模型权重 ============ # ============ 模型权重 ============

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

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

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

86
Docs/DevLogs/Day27.md Normal file
View File

@@ -0,0 +1,86 @@
## Remotion 描边修复 + 字体样式扩展 + TypeScript 修复 (Day 27)
### 概述
修复标题/字幕描边渲染问题(描边过粗 + 副标题重影),扩展字体样式选项(标题 4→12、字幕 4→8修复 Remotion 项目 TypeScript 类型错误。
---
## ✅ 改动内容
### 1. 描边渲染修复(标题 + 字幕)
- **问题**: 标题黑色描边过粗,副标题出现重影/鬼影
- **根因**: `buildTextShadow` 用 4 方向 `textShadow` 模拟描边 — 对角线叠加导致描边视觉上比实际 `stroke_size` 更粗4 角方向在中间有间隙和叠加,造成重影
- **修复**: 改用 CSS 原生描边 `-webkit-text-stroke` + `paint-order: stroke fill`Remotion 用 Chromium 渲染,完美支持)
- **旧方案**:
```javascript
textShadow: `-8px -8px 0 #000, 8px -8px 0 #000, -8px 8px 0 #000, 8px 8px 0 #000, 0 0 16px rgba(0,0,0,0.5), 0 2px 4px rgba(0,0,0,0.3)`
```
- **新方案**:
```javascript
WebkitTextStroke: `5px #000000`,
paintOrder: 'stroke fill',
textShadow: `0 2px 4px rgba(0,0,0,0.3)`,
```
- 同时将所有预设样式的 `stroke_size` 从 8 降到 5配合原生描边视觉更干净
### 2. 字体样式扩展
**标题样式**: 4 个 → 12 个(+8
| ID | 样式名 | 字体 | 配色 |
|----|--------|------|------|
| title_pangmen | 庞门正道 | 庞门正道标题体3.0 | 白字黑描 |
| title_round | 优设标题圆 | 优设标题圆 | 白字紫描 |
| title_alibaba | 阿里数黑体 | 阿里巴巴数黑体 | 白字黑描 |
| title_chaohei | 文道潮黑 | 文道潮黑 | 青蓝字深蓝描 |
| title_wujie | 无界黑 | 标小智无界黑 | 白字深灰描 |
| title_houdi | 厚底黑 | Aa厚底黑 | 红字深黑描 |
| title_banyuan | 寒蝉半圆体 | 寒蝉半圆体 | 白字黑描 |
| title_jixiang | 欣意吉祥宋 | 字体圈欣意吉祥宋 | 金字棕描 |
**字幕样式**: 4 个 → 8 个(+4
| ID | 样式名 | 字体 | 高亮色 |
|----|--------|------|--------|
| subtitle_pink | 少女粉 | DingTalk JinBuTi | 粉色 #FF69B4 |
| subtitle_lime | 清新绿 | DingTalk Sans | 荧光绿 #76FF03 |
| subtitle_gold | 金色隶书 | 阿里妈妈刀隶体 | 金色 #FDE68A |
| subtitle_kai | 楷体红字 | SimKai | 红色 #FF4444 |
### 3. TypeScript 类型错误修复
- **Root.tsx**: `Composition` 泛型类型与 `calculateMetadata` 参数类型不匹配 — 内联 `calculateMetadata` 并显式标注参数类型,`defaultProps` 使用 `satisfies VideoProps` 约束
- **Video.tsx**: `VideoProps` 接口添加 `[key: string]: unknown` 索引签名,兼容 Remotion 要求的 `Record<string, unknown>` 约束
- **VideoLayer.tsx**: `OffthreadVideo` 组件不支持 `loop` prop — 移除(该 prop 原本就被忽略)
### 4. 进度条文案还原
- **问题**: 进度条显示后端推送的详细阶段消息(如"正在合成唇型"),用户希望只显示"正在AI生成中..."
- **修复**: `HomePage.tsx` 进度条文案从 `{currentTask.message || "正在AI生成中..."}` 改为固定 `正在AI生成中...`
---
## 📁 修改文件清单
| 文件 | 改动 |
|------|------|
| `remotion/src/components/Title.tsx` | `buildTextShadow` → `buildStrokeStyle`CSS 原生描边),标题+副标题同时生效 |
| `remotion/src/components/Subtitles.tsx` | `buildTextShadow` → `buildStrokeStyle`CSS 原生描边) |
| `remotion/src/Root.tsx` | 修复 `Composition` 泛型类型、`calculateMetadata` 参数类型 |
| `remotion/src/Video.tsx` | `VideoProps` 添加索引签名 |
| `remotion/src/components/VideoLayer.tsx` | 移除 `OffthreadVideo` 不支持的 `loop` prop |
| `backend/assets/styles/title.json` | 标题样式从 4 个扩展到 12 个,`stroke_size` 8→5 |
| `backend/assets/styles/subtitle.json` | 字幕样式从 4 个扩展到 8 个 |
| `frontend/.../HomePage.tsx` | 进度条文案还原为固定"正在AI生成中..." |
---
## 🔍 验证
- `npx tsc --noEmit` — 零错误
- `npm run build:render` — 渲染脚本编译成功
- `npm run build`(前端)— 零报错
- 描边:标题/副标题/字幕使用 CSS 原生描边,无重影、无虚胖
- 样式选择:前端下拉可加载全部 12 个标题 + 8 个字幕样式

View File

@@ -151,6 +151,33 @@ body {
| `sm:` | ≥ 640px | 平板/桌面 | | `sm:` | ≥ 640px | 平板/桌面 |
| `lg:` | ≥ 1024px | 大屏桌面 | | `lg:` | ≥ 1024px | 大屏桌面 |
### embedded 组件模式
合并板块时,子组件通过 `embedded?: boolean` prop 控制是否渲染外层卡片容器和主标题。
```tsx
// embedded=false独立使用渲染完整卡片
<div className="bg-white/5 rounded-2xl p-6 border border-white/10">
<h2></h2>
{content}
</div>
// embedded=true嵌入父卡片只渲染内容
{content}
```
- 子标题使用 `<h3 className="text-sm font-medium text-gray-400">`
- 分隔线使用 `<div className="border-t border-white/10 my-4" />`
- 移动端标题行避免 `whitespace-nowrap`,长描述文字可用 `hidden sm:inline` 在移动端隐藏
### 按钮视觉层级
| 层级 | 样式 | 用途 |
|------|------|------|
| 主操作 | `px-4 py-2 text-sm font-medium bg-gradient-to-r from-purple-600 to-pink-600 shadow-sm` | 生成配音、立即发布 |
| 辅助操作 | `px-2 py-1 text-xs bg-white/10 rounded` | 刷新、上传、语速 |
| 触屏可见 | `opacity-40 group-hover:opacity-100` | 列表行内操作(编辑/删除) |
--- ---
## API 请求规范 ## API 请求规范
@@ -259,9 +286,35 @@ import { formatDate } from '@/shared/lib/media';
### 刷新回顶部(统一体验) ### 刷新回顶部(统一体验)
- 长页面(如首页/发布页)在首次挂载时统一回到顶部,避免浏览器恢复旧滚动位置导致进入即跳到中部 - 长页面(如首页/发布页)在首次挂载时统一回到顶部。
- 推荐实现:`useEffect(() => { window.scrollTo({ top: 0, left: 0, behavior: 'auto' }); }, [])` - **必须**在页面级 `useEffect` 中设置 `history.scrollRestoration = "manual"` 禁用浏览器原生滚动恢复。
- 列表内自动定位(素材/历史记录)应跳过恢复后的首次触发,防止刷新后页面二次跳动 - 调用 `window.scrollTo({ top: 0, left: 0, behavior: "auto" })` 并追加 200ms 延迟兜底(防止异步 effect 覆盖)
- **列表自动滚动必须使用时间门控**:页面加载后 1 秒内禁止所有列表自动滚动效果(`scrollEffectsEnabled` ref防止持久化恢复 + 异步数据加载触发 `scrollIntoView` 导致页面跳动。
- 推荐模式:
```typescript
// 页面级HomePage / PublishPage
useEffect(() => {
if (typeof window === "undefined") return;
if ("scrollRestoration" in history) history.scrollRestoration = "manual";
window.scrollTo({ top: 0, left: 0, behavior: "auto" });
const timer = setTimeout(() => window.scrollTo({ top: 0, left: 0, behavior: "auto" }), 200);
return () => clearTimeout(timer);
}, []);
// Controller 级(列表滚动时间门控)
const scrollEffectsEnabled = useRef(false);
useEffect(() => {
const timer = setTimeout(() => { scrollEffectsEnabled.current = true; }, 1000);
return () => clearTimeout(timer);
}, []);
// 列表滚动 effectBGM/素材/视频等)
useEffect(() => {
if (!selectedId || !scrollEffectsEnabled.current) return;
target?.scrollIntoView({ block: "nearest", behavior: "smooth" });
}, [selectedId, list]);
```
### 路由预取 ### 路由预取

View File

@@ -5,14 +5,12 @@ ViGent2 的前端界面,采用 Next.js 16 + TailwindCSS 构建。
## ✨ 核心功能 ## ✨ 核心功能
### 1. 视频生成 (`/`) ### 1. 视频生成 (`/`)
- **素材管理**: 拖拽上传人物视频,实时预览 - **一、文案提取与编辑**: 文案输入/提取/翻译/保存
- **素材重命名**: 支持在列表中直接重命名素材 - **二、标题与字幕**: 片头标题/副标题/字幕样式配置;短暂显示/常驻显示对标题和副标题同时生效
- **文案配音**: 集成 EdgeTTS,支持多音色选择 (云溪 / 晓晓) - **三、配音**: 配音方式(EdgeTTS/声音克隆)+ 配音列表(生成/试听/管理)合并为一个板块
- **AI 标题/标签**: 一键生成视频标题与标签 (Day 14) - **四、素材编辑**: 视频素材(上传/选择/管理)+ 时间轴编辑(波形/色块/拖拽排序)合并为一个板块
- **标题/字幕样式**: 样式选择 + 预览 + 字号调节 (Day 16) - **五、背景音乐**: 试听 + 音量控制 + 选择持久化
- **背景音乐**: 试听 + 音量控制 + 选择持久化 (Day 16) - **六、作品**(右栏): 作品列表 + 作品预览合并为一个板块
- **交互优化**: 选择项持久化、列表内定位、刷新回顶部 (Day 16)。
- **预览一致性**: 标题/字幕预览按素材分辨率缩放,效果更接近成片 (Day 17)。
- **进度追踪**: 实时显示视频生成进度 (10% -> 100%)。 - **进度追踪**: 实时显示视频生成进度 (10% -> 100%)。
- **作品预览**: 生成完成后直接播放下载(作品预览 + 历史作品)。 - **作品预览**: 生成完成后直接播放下载(作品预览 + 历史作品)。
- **预览优化**: 预览视频 `metadata` 预取,首帧加载更快。 - **预览优化**: 预览视频 `metadata` 预取,首帧加载更快。
@@ -52,13 +50,14 @@ ViGent2 的前端界面,采用 Next.js 16 + TailwindCSS 构建。
- **画面比例控制**: 时间轴顶部支持 `9:16 / 16:9` 输出比例选择,设置持久化并透传后端。 - **画面比例控制**: 时间轴顶部支持 `9:16 / 16:9` 输出比例选择,设置持久化并透传后端。
### 5. 字幕与标题 [Day 13 新增] ### 5. 字幕与标题 [Day 13 新增]
- **片头标题**: 可选输入,限制 15 字;支持短暂显示 / 常驻显示”默认短暂显示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 新增]
- **试听预览**: 点击试听即选中,音量滑块实时生效。 - **试听预览**: 点击试听即选中,音量滑块实时生效。
@@ -66,8 +65,9 @@ ViGent2 的前端界面,采用 Next.js 16 + TailwindCSS 构建。
### 7. 账户设置 [Day 15 新增] ### 7. 账户设置 [Day 15 新增]
- **手机号登录**: 11位中国手机号验证登录。 - **手机号登录**: 11位中国手机号验证登录。
- **账户下拉菜单**: 显示有效期 + 修改密码 + 安全退出。 - **账户下拉菜单**: 显示手机号(中间四位脱敏)+ 有效期 + 修改密码 + 安全退出。
- **修改密码**: 弹窗输入当前密码与新密码,修改后强制重新登录。 - **修改密码**: 弹窗输入当前密码与新密码,修改后强制重新登录。
- **登录即时生效**: 登录成功后 AuthContext 立即写入用户数据,无需刷新即显示手机号。
### 8. 付费开通会员 (`/pay`) ### 8. 付费开通会员 (`/pay`)
- **支付宝电脑网站支付**: 跳转支付宝官方收银台,支持扫码/账号登录/余额等多种支付方式。 - **支付宝电脑网站支付**: 跳转支付宝官方收银台,支持扫码/账号登录/余额等多种支付方式。
@@ -77,7 +77,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)。
- **一键填入**: 提取结果直接填充至视频生成输入框。 - **一键填入**: 提取结果直接填充至视频生成输入框。
- **智能交互**: 实时进度展示,防误触设计。 - **智能交互**: 实时进度展示,防误触设计。
@@ -141,5 +142,8 @@ src/
## 🎨 设计规范 ## 🎨 设计规范
- **主色调**: 深紫/黑色系 (Dark Mode) - **主色调**: 深紫/黑色系 (Dark Mode)
- **交互**: 悬停微动画 (Hover Effects) - **交互**: 悬停微动画 (Hover Effects);操作按钮默认半透明可见 (opacity-40)hover 时全亮,兼顾触屏设备
- **响应式**: 适配桌面端大屏操作 - **响应式**: 适配桌面端与移动端;发布页平台卡片响应式布局(移动端紧凑/桌面端宽松)
- **滚动体验**: 列表滚动条统一隐藏 (hide-scrollbar);刷新后自动回到顶部(禁用浏览器滚动恢复 + 列表 scroll 时间门控)
- **样式预览**: 浮动预览窗口,桌面端左上角 280px移动端右下角 160px不遮挡控件
- **输入辅助**: 标题/副标题输入框实时字数计数器,超限变红

View File

@@ -289,3 +289,4 @@ WhisperService(device="cuda:0") # 或 "cuda:1"
| 2026-01-29 | 1.0.0 | 初始版本,使用 faster-whisper + Remotion 实现逐字高亮字幕和片头标题 | | 2026-01-29 | 1.0.0 | 初始版本,使用 faster-whisper + Remotion 实现逐字高亮字幕和片头标题 |
| 2026-02-10 | 1.1.0 | 更新架构图:多素材 concat-then-infer、预生成配音选项 | | 2026-02-10 | 1.1.0 | 更新架构图:多素材 concat-then-infer、预生成配音选项 |
| 2026-01-30 | 1.0.1 | 字幕高亮样式与标题动画优化,视觉表现更清晰 | | 2026-01-30 | 1.0.1 | 字幕高亮样式与标题动画优化,视觉表现更清晰 |
| 2026-02-25 | 1.2.0 | 字幕时间戳从线性插值改为 Whisper 节奏映射,修复长视频字幕漂移 |

View File

@@ -1,8 +1,8 @@
# ViGent2 开发任务清单 (Task Log) # ViGent2 开发任务清单 (Task Log)
**项目**: ViGent2 数字人口播视频生成系统 **项目**: ViGent2 数字人口播视频生成系统
**进度**: 100% (Day 25 - 支付宝付费开通会员) **进度**: 100% (Day 27 - Remotion 描边修复 + 字体样式扩展)
**更新时间**: 2026-02-11 **更新时间**: 2026-02-26
--- ---
@@ -10,15 +10,46 @@
> 这里记录了每一天的核心开发内容与 milestone。 > 这里记录了每一天的核心开发内容与 milestone。
### Day 25: 支付宝付费开通会员 (Current) ### Day 27: Remotion 描边修复 + 字体样式扩展 (Current)
- [x] **支付宝电脑网站支付**: 集成 `python-alipay-sdk`,支持 `alipay.trade.page.pay` 跳转支付宝收银台 - [x] **描边渲染修复**: 标题/副标题/字幕从 `textShadow` 4 方向模拟改为 CSS 原生 `-webkit-text-stroke` + `paint-order: stroke fill`,修复描边过粗和副标题重影问题
- [x] **payment_token 机制**: 登录时未激活/已过期用户返回 403 + 短时效 JWT30 分钟),安全传递身份到付费页 - [x] **字体样式扩展**: 标题样式 4→12 个(+庞门正道/优设标题圆/阿里数黑体/文道潮黑/无界黑/厚底黑/寒蝉半圆体/欣意吉祥宋),字幕样式 4→8 个(+少女粉/清新绿/金色隶书/楷体红字)
- [x] **异步通知回调**: `POST /api/payment/notify` 验签 → 更新订单 → 激活用户is_active=true, expires_at=+365天 - [x] **描边参数优化**: 所有预设 `stroke_size` 从 8 降至 4~5配合原生描边视觉更干净
- [x] **前端付费页**: `/pay` 页面,首次访问创建订单并跳转收银台,支付完成返回后轮询状态 - [x] **TypeScript 类型修复**: Root.tsx `Composition` 泛型与 `calculateMetadata` 参数类型对齐Video.tsx `VideoProps` 添加索引签名兼容 `Record<string, unknown>`VideoLayer.tsx 移除 `OffthreadVideo` 不支持的 `loop` prop
- [x] **is_active 安全兜底**: `deps.py` 在登录和鉴权两处均检查 is_active到期自动停用并清理 session - [x] **进度条文案还原**: 进度条从显示后端推送消息改回固定 `正在AI生成中...`
- [x] **orders 数据层**: 新增 `repositories/orders.py` + `orders` 数据库表。
- [x] **登录流程适配**: 登录接口返回 PAYMENT_REQUIRED前端 auth.ts 处理 paymentToken 跳转。 ### Day 26: 前端优化:板块合并 + 序号标题 + UI 精细化
- [x] **部署文档**: 新增 `Docs/ALIPAY_DEPLOY.md`含密钥配置、PEM 格式、产品开通等完整指南 - [x] **板块合并**: 首页 9 个独立板块合并为 5 个主板块(配音方式+配音列表→三、配音;视频素材+时间轴→四、素材编辑;历史作品+作品预览→六、作品)
- [x] **中文序号标题**: 一~十编号(首页一~六,发布页七~十),移除所有 emoji 图标。
- [x] **embedded 模式**: 6 个组件支持 `embedded` prop嵌入时不渲染外层卡片/标题。
- [x] **配音列表两行布局**: embedded 模式第 1 行语速+生成配音(右对齐),第 2 行配音列表+刷新。
- [x] **子组件自渲染子标题**: MaterialSelector/TimelineEditor embedded 时自渲染 h3 子标题+操作按钮同行。
- [x] **下拉对齐**: TitleSubtitlePanel 标签统一 `w-20`,下拉 `w-1/3 min-w-[100px]`,垂直对齐。
- [x] **参考音频文案简化**: 底部段落移至标题旁,简化为 `(上传3-10秒语音样本)`
- [x] **账户手机号显示**: AccountSettingsDropdown 新增手机号显示。
- [x] **标题显示模式对副标题生效**: payload 条件修复 + UI 下拉上移至板块标题行。
- [x] **登录后用户信息立即可用**: AuthContext 暴露 `setUser`,登录成功后立即写入用户数据,修复登录后显示"未知账户"的问题。
- [x] **文案微调**: 素材描述改为"上传自拍视频最多可选4个";显示模式选项加"标题"前缀。
- [x] **UI/UX 体验优化**: 操作按钮移动端可见opacity-40、手机号脱敏、标题字数计数器、时间轴拖拽抓手图标、截取滑块放大。
- [x] **代码质量修复**: 密码弹窗 success 清空、MaterialSelector useMemo + disabled 守卫、TimelineEditor useMemo。
- [x] **发布页响应式布局**: 平台账号卡片单行布局,移动端紧凑(小图标/小按钮),桌面端宽松(与其他板块风格一致)。
- [x] **移动端刷新回顶部**: `scrollRestoration = "manual"` + 列表 scroll 时间门控(`scrollEffectsEnabled` ref1 秒内禁止自动滚动)+ 延迟兜底 `scrollTo(0,0)`
- [x] **移动端样式预览缩小**: FloatingStylePreview 移动端宽度缩至 160px位置改为右下角不遮挡样式调节控件。
- [x] **列表滚动条统一隐藏**: 所有列表BGM/配音/作品/素材/文案提取)滚动条改回 `hide-scrollbar`
- [x] **移动端配音/素材适配**: VoiceSelector 按钮移动端缩小(`px-2 sm:px-4`修复克隆声音不可见MaterialSelector 标题行移除 `whitespace-nowrap`,描述移动端隐藏,修复刷新按钮溢出。
- [x] **生成配音按钮放大**: 从辅助尺寸(`text-xs px-2 py-1`)升级为主操作尺寸(`text-sm font-medium px-4 py-2`),新增阴影。
- [x] **生成进度条位置调整**: 从"六、作品"卡片内部提取到右栏独立卡片,显示在作品卡片上方,更醒目。
- [x] **LatentSync 超时修复**: httpx 超时从 1200s20 分钟)改为 3600s1 小时),修复 2 分钟以上视频口型推理超时回退问题。
- [x] **字幕时间戳节奏映射**: `whisper_service.py` 从全程线性插值改为 Whisper 逐词节奏映射,修复长视频字幕漂移。
### Day 25: 文案提取修复 + 自定义提示词 + 片头副标题
- [x] **抖音文案提取修复**: yt-dlp Fresh cookies 报错,重写 `_download_douyin_manual` 为移动端分享页 + 自动获取 ttwid 方案。
- [x] **清理 DOUYIN_COOKIE**: 新方案不再需要手动维护 Cookie`.env`/`config.py`/`service.py` 全面删除。
- [x] **AI 智能改写自定义提示词**: 后端 `rewrite_script()` 支持 `custom_prompt` 参数;前端 checkbox 旁新增折叠式提示词编辑区localStorage 持久化。
- [x] **SSR 构建修复**: `useState` 初始化 `localStorage` 访问加 `typeof window` 守卫,修复 `npm run build` 报错。
- [x] **片头副标题**: 新增 secondary_title后端/Remotion/前端全链路AI 同时生成独立样式配置20 字限制。
- [x] **前端文案修正**: "AI 洗稿结果"→"AI 改写结果"。
- [x] **yt-dlp 升级**: `2025.12.08``2026.2.21`
- [x] **参考音频中文文件名修复**: `sanitize_filename()` 将存储路径清洗为 ASCII 安全字符,纯中文名哈希兜底,原始名保留为展示名。
### Day 24: 鉴权到期治理 + 多素材时间轴稳定性修复 ### 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效果) 字幕。
- 🎨 **样式预设** - 标题/字幕样式选择 + 预览 + 字号调节,支持自定义字体库。 - 🎨 **样式预设** - 12 种标题 + 8 种字幕样式预设,支持预览 + 字号调节 + 自定义字体库。CSS 原生描边渲染,清晰无重影。
- 🏷️ **标题显示模式** - 片头标题支持 `短暂显示` / `常驻显示`默认短暂显示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 持久化。
@@ -45,7 +46,7 @@
| 领域 | 核心技术 | 说明 | | 领域 | 核心技术 | 说明 |
|------|----------|------| |------|----------|------|
| **前端** | Next.js 16 | TypeScript, TailwindCSS, SWR, wavesurfer.js | | **前端** | Next.js 16 | TypeScript, TailwindCSS, SWR, wavesurfer.js |
| **后端** | FastAPI | Python 3.10, AsyncIO, PM2 | | **后端** | FastAPI | Python 3.12, AsyncIO, PM2 |
| **数据库** | Supabase | PostgreSQL, Storage (本地/S3), Auth | | **数据库** | Supabase | PostgreSQL, Storage (本地/S3), Auth |
| **唇形同步** | LatentSync 1.6 | PyTorch 2.5, Diffusers, DeepCache | | **唇形同步** | LatentSync 1.6 | PyTorch 2.5, Diffusers, DeepCache |
| **声音克隆** | CosyVoice 3.0 | 0.5B 参数量9 语言 + 18 方言 | | **声音克隆** | CosyVoice 3.0 | 0.5B 参数量9 语言 + 18 方言 |
@@ -61,7 +62,7 @@
### 部署运维 ### 部署运维
- **[部署手册 (DEPLOY_MANUAL.md)](Docs/DEPLOY_MANUAL.md)** - 👈 **部署请看这里**!包含完整的环境搭建步骤。 - **[部署手册 (DEPLOY_MANUAL.md)](Docs/DEPLOY_MANUAL.md)** - 👈 **部署请看这里**!包含完整的环境搭建步骤。
- [参考音频服务部署 (COSYVOICE3_DEPLOY.md)](Docs/COSYVOICE3_DEPLOY.md) - 声音克隆模型部署指南。 - [参考音频服务部署 (COSYVOICE3_DEPLOY.md)](Docs/COSYVOICE3_DEPLOY.md) - 声音克隆模型部署指南。
- [LatentSync 部署指南](models/LatentSync/DEPLOY.md) - 唇形同步模型独立部署。 - [LatentSync 部署指南 (LATENTSYNC_DEPLOY.md)](Docs/LATENTSYNC_DEPLOY.md) - 唇形同步模型独立部署。
- [Supabase 部署指南 (SUPABASE_DEPLOY.md)](Docs/SUPABASE_DEPLOY.md) - Supabase 与认证系统配置。 - [Supabase 部署指南 (SUPABASE_DEPLOY.md)](Docs/SUPABASE_DEPLOY.md) - Supabase 与认证系统配置。
- [支付宝部署指南 (ALIPAY_DEPLOY.md)](Docs/ALIPAY_DEPLOY.md) - 支付宝付费开通会员配置。 - [支付宝部署指南 (ALIPAY_DEPLOY.md)](Docs/ALIPAY_DEPLOY.md) - 支付宝付费开通会员配置。
@@ -69,6 +70,8 @@
- [后端开发指南](Docs/BACKEND_README.md) - 接口规范与开发流程。 - [后端开发指南](Docs/BACKEND_README.md) - 接口规范与开发流程。
- [后端开发规范](Docs/BACKEND_DEV.md) - 分层约定与开发习惯。 - [后端开发规范](Docs/BACKEND_DEV.md) - 分层约定与开发习惯。
- [前端开发指南](Docs/FRONTEND_DEV.md) - UI 组件与页面规范。 - [前端开发指南](Docs/FRONTEND_DEV.md) - UI 组件与页面规范。
- [前端组件文档](Docs/FRONTEND_README.md) - 组件结构与板块说明。
- [Remotion 字幕部署 (SUBTITLE_DEPLOY.md)](Docs/SUBTITLE_DEPLOY.md) - 字幕渲染服务部署。
- [开发日志 (DevLogs)](Docs/DevLogs/) - 每日开发进度与技术决策记录。 - [开发日志 (DevLogs)](Docs/DevLogs/) - 每日开发进度与技术决策记录。
--- ---

View File

@@ -25,7 +25,7 @@ LATENTSYNC_USE_SERVER=true
# LATENTSYNC_API_URL=http://localhost:8007 # LATENTSYNC_API_URL=http://localhost:8007
# 推理步数 (20-50, 越高质量越好,速度越慢) # 推理步数 (20-50, 越高质量越好,速度越慢)
LATENTSYNC_INFERENCE_STEPS=40 LATENTSYNC_INFERENCE_STEPS=20
# 引导系数 (1.0-3.0, 越高唇同步越准,但可能抖动) # 引导系数 (1.0-3.0, 越高唇同步越准,但可能抖动)
LATENTSYNC_GUIDANCE_SCALE=2.0 LATENTSYNC_GUIDANCE_SCALE=2.0
@@ -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

@@ -369,7 +369,7 @@ class LipSyncService:
} }
try: try:
async with httpx.AsyncClient(timeout=1200.0) as client: async with httpx.AsyncClient(timeout=3600.0) as client:
# 先检查健康状态 # 先检查健康状态
try: try:
resp = await client.get(f"{server_url}/health", timeout=5.0) resp = await client.get(f"{server_url}/health", timeout=5.0)

View File

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

@@ -247,19 +247,67 @@ class WhisperService:
line_segments = split_segment_to_lines(all_words, max_chars) line_segments = split_segment_to_lines(all_words, max_chars)
all_segments.extend(line_segments) all_segments.extend(line_segments)
# 如果提供了 original_text用原文替换 Whisper 转录文字 # 如果提供了 original_text用原文替换 Whisper 转录文字,保留语音节奏
if original_text and original_text.strip() and whisper_first_start is not None: if original_text and original_text.strip() and whisper_first_start is not None:
logger.info(f"Using original_text for subtitles (len={len(original_text)}), " # 收集 Whisper 逐字时间戳(保留真实语音节奏)
f"Whisper time range: {whisper_first_start:.2f}-{whisper_last_end:.2f}s") whisper_chars = []
# 用 split_word_to_chars 拆分原文 for seg in all_segments:
whisper_chars.extend(seg.get("words", []))
# 用原文字符 + Whisper 节奏生成新的时间戳
orig_chars = split_word_to_chars( orig_chars = split_word_to_chars(
original_text.strip(), original_text.strip(),
whisper_first_start, whisper_first_start,
whisper_last_end whisper_last_end
) )
if orig_chars:
if orig_chars and len(whisper_chars) >= 2:
# 将原文字符按比例映射到 Whisper 的时间节奏上
n_w = len(whisper_chars)
n_o = len(orig_chars)
w_starts = [c["start"] for c in whisper_chars]
w_final_end = whisper_chars[-1]["end"]
logger.info(
f"Using original_text for subtitles (len={len(original_text)}), "
f"rhythm-mapping {n_o} orig chars onto {n_w} Whisper chars, "
f"time range: {whisper_first_start:.2f}-{whisper_last_end:.2f}s"
)
remapped = []
for i, oc in enumerate(orig_chars):
# 原文第 i 个字符对应 Whisper 时间线的位置
pos = (i / n_o) * n_w
idx = min(int(pos), n_w - 1)
frac = pos - idx
t_start = (
w_starts[idx] + frac * (w_starts[idx + 1] - w_starts[idx])
if idx < n_w - 1
else w_starts[idx] + frac * (w_final_end - w_starts[idx])
)
# 结束时间 = 下一个字符的开始时间
pos_next = ((i + 1) / n_o) * n_w
idx_n = min(int(pos_next), n_w - 1)
frac_n = pos_next - idx_n
t_end = (
w_starts[idx_n] + frac_n * (w_starts[idx_n + 1] - w_starts[idx_n])
if idx_n < n_w - 1
else w_starts[idx_n] + frac_n * (w_final_end - w_starts[idx_n])
)
remapped.append({
"word": oc["word"],
"start": round(t_start, 3),
"end": round(t_end, 3),
})
all_segments = split_segment_to_lines(remapped, max_chars)
logger.info(f"Rebuilt {len(all_segments)} subtitle segments (rhythm-mapped)")
elif orig_chars:
# Whisper 字符不足,退回线性插值
all_segments = split_segment_to_lines(orig_chars, max_chars) all_segments = split_segment_to_lines(orig_chars, max_chars)
logger.info(f"Rebuilt {len(all_segments)} subtitle segments from original text") logger.info(f"Rebuilt {len(all_segments)} subtitle segments (linear fallback)")
logger.info(f"Generated {len(all_segments)} subtitle segments") logger.info(f"Generated {len(all_segments)} subtitle segments")
return {"segments": all_segments} return {"segments": all_segments}

View File

@@ -54,5 +54,61 @@
"letter_spacing": 1, "letter_spacing": 1,
"bottom_margin": 72, "bottom_margin": 72,
"is_default": false "is_default": false
},
{
"id": "subtitle_pink",
"label": "少女粉",
"font_file": "DingTalk JinBuTi.ttf",
"font_family": "DingTalkJinBuTi",
"font_size": 56,
"highlight_color": "#FF69B4",
"normal_color": "#FFFFFF",
"stroke_color": "#1A0010",
"stroke_size": 3,
"letter_spacing": 2,
"bottom_margin": 80,
"is_default": false
},
{
"id": "subtitle_lime",
"label": "清新绿",
"font_file": "DingTalk Sans.ttf",
"font_family": "DingTalkSans",
"font_size": 50,
"highlight_color": "#76FF03",
"normal_color": "#FFFFFF",
"stroke_color": "#001A00",
"stroke_size": 3,
"letter_spacing": 1,
"bottom_margin": 78,
"is_default": false
},
{
"id": "subtitle_gold",
"label": "金色隶书",
"font_file": "阿里妈妈刀隶体.ttf",
"font_family": "AliMamaDaoLiTi",
"font_size": 56,
"highlight_color": "#FDE68A",
"normal_color": "#E8D5B0",
"stroke_color": "#2B1B00",
"stroke_size": 3,
"letter_spacing": 3,
"bottom_margin": 80,
"is_default": false
},
{
"id": "subtitle_kai",
"label": "楷体红字",
"font_file": "simkai.ttf",
"font_family": "SimKai",
"font_size": 54,
"highlight_color": "#FF4444",
"normal_color": "#FFFFFF",
"stroke_color": "#000000",
"stroke_size": 3,
"letter_spacing": 2,
"bottom_margin": 80,
"is_default": false
} }
] ]

View File

@@ -7,7 +7,7 @@
"font_size": 90, "font_size": 90,
"color": "#FFFFFF", "color": "#FFFFFF",
"stroke_color": "#000000", "stroke_color": "#000000",
"stroke_size": 8, "stroke_size": 5,
"letter_spacing": 5, "letter_spacing": 5,
"top_margin": 62, "top_margin": 62,
"font_weight": 900, "font_weight": 900,
@@ -21,7 +21,7 @@
"font_size": 72, "font_size": 72,
"color": "#FFFFFF", "color": "#FFFFFF",
"stroke_color": "#000000", "stroke_color": "#000000",
"stroke_size": 8, "stroke_size": 5,
"letter_spacing": 4, "letter_spacing": 4,
"top_margin": 60, "top_margin": 60,
"font_weight": 900, "font_weight": 900,
@@ -35,7 +35,7 @@
"font_size": 70, "font_size": 70,
"color": "#FDE68A", "color": "#FDE68A",
"stroke_color": "#2B1B00", "stroke_color": "#2B1B00",
"stroke_size": 8, "stroke_size": 5,
"letter_spacing": 3, "letter_spacing": 3,
"top_margin": 58, "top_margin": 58,
"font_weight": 800, "font_weight": 800,
@@ -49,10 +49,122 @@
"font_size": 72, "font_size": 72,
"color": "#FFFFFF", "color": "#FFFFFF",
"stroke_color": "#1F0A00", "stroke_color": "#1F0A00",
"stroke_size": 8, "stroke_size": 5,
"letter_spacing": 4, "letter_spacing": 4,
"top_margin": 60, "top_margin": 60,
"font_weight": 900, "font_weight": 900,
"is_default": false "is_default": false
},
{
"id": "title_pangmen",
"label": "庞门正道",
"font_file": "title/庞门正道标题体3.0.ttf",
"font_family": "PangMenZhengDao",
"font_size": 80,
"color": "#FFFFFF",
"stroke_color": "#000000",
"stroke_size": 5,
"letter_spacing": 5,
"top_margin": 60,
"font_weight": 900,
"is_default": false
},
{
"id": "title_round",
"label": "优设标题圆",
"font_file": "title/优设标题圆.otf",
"font_family": "YouSheBiaoTiYuan",
"font_size": 78,
"color": "#FFFFFF",
"stroke_color": "#4A1A6B",
"stroke_size": 5,
"letter_spacing": 4,
"top_margin": 60,
"font_weight": 900,
"is_default": false
},
{
"id": "title_alibaba",
"label": "阿里数黑体",
"font_file": "title/阿里巴巴数黑体.ttf",
"font_family": "AlibabaShuHeiTi",
"font_size": 72,
"color": "#FFFFFF",
"stroke_color": "#000000",
"stroke_size": 4,
"letter_spacing": 3,
"top_margin": 60,
"font_weight": 900,
"is_default": false
},
{
"id": "title_chaohei",
"label": "文道潮黑",
"font_file": "title/文道潮黑.ttf",
"font_family": "WenDaoChaoHei",
"font_size": 76,
"color": "#00E5FF",
"stroke_color": "#001A33",
"stroke_size": 5,
"letter_spacing": 4,
"top_margin": 60,
"font_weight": 900,
"is_default": false
},
{
"id": "title_wujie",
"label": "无界黑",
"font_file": "title/标小智无界黑.otf",
"font_family": "BiaoXiaoZhiWuJieHei",
"font_size": 74,
"color": "#FFFFFF",
"stroke_color": "#1A1A1A",
"stroke_size": 4,
"letter_spacing": 3,
"top_margin": 60,
"font_weight": 900,
"is_default": false
},
{
"id": "title_houdi",
"label": "厚底黑",
"font_file": "title/Aa厚底黑.ttf",
"font_family": "AaHouDiHei",
"font_size": 76,
"color": "#FF6B6B",
"stroke_color": "#1A0000",
"stroke_size": 5,
"letter_spacing": 4,
"top_margin": 60,
"font_weight": 900,
"is_default": false
},
{
"id": "title_banyuan",
"label": "寒蝉半圆体",
"font_file": "title/寒蝉半圆体.otf",
"font_family": "HanChanBanYuan",
"font_size": 78,
"color": "#FFFFFF",
"stroke_color": "#000000",
"stroke_size": 5,
"letter_spacing": 4,
"top_margin": 60,
"font_weight": 900,
"is_default": false
},
{
"id": "title_jixiang",
"label": "欣意吉祥宋",
"font_file": "title/字体圈欣意吉祥宋.ttf",
"font_family": "XinYiJiXiangSong",
"font_size": 70,
"color": "#FDE68A",
"stroke_color": "#2B1B00",
"stroke_size": 5,
"letter_spacing": 3,
"top_margin": 58,
"font_weight": 800,
"is_default": false
} }
] ]

View File

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

@@ -3,9 +3,11 @@
import { useState } from 'react'; import { useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { login } from "@/shared/lib/auth"; import { login } from "@/shared/lib/auth";
import { useAuth } from "@/shared/contexts/AuthContext";
export default function LoginPage() { export default function LoginPage() {
const router = useRouter(); const router = useRouter();
const { setUser } = useAuth();
const [phone, setPhone] = useState(''); const [phone, setPhone] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [error, setError] = useState(''); const [error, setError] = useState('');
@@ -29,6 +31,7 @@ export default function LoginPage() {
sessionStorage.setItem('payment_token', result.paymentToken); sessionStorage.setItem('payment_token', result.paymentToken);
router.push('/pay'); router.push('/pay');
} else if (result.success) { } else if (result.success) {
if (result.user) setUser(result.user);
router.push('/'); router.push('/');
} else { } else {
setError(result.message || '登录失败'); setError(result.message || '登录失败');

View File

@@ -106,6 +106,10 @@ export default function AccountSettingsDropdown() {
{/* 下拉菜单 */} {/* 下拉菜单 */}
{isOpen && ( {isOpen && (
<div className="absolute right-0 mt-2 bg-gray-800 border border-white/10 rounded-lg shadow-xl z-[160] overflow-hidden whitespace-nowrap"> <div className="absolute right-0 mt-2 bg-gray-800 border border-white/10 rounded-lg shadow-xl z-[160] overflow-hidden whitespace-nowrap">
{/* 账户名称 */}
<div className="px-3 py-2 border-b border-white/10 text-center">
<div className="text-sm text-white font-medium">{user?.phone ? `${user.phone.slice(0, 3)}****${user.phone.slice(-4)}` : '未知账户'}</div>
</div>
{/* 有效期显示 */} {/* 有效期显示 */}
<div className="px-3 py-2 border-b border-white/10 text-center"> <div className="px-3 py-2 border-b border-white/10 text-center">
<div className="text-xs text-gray-400"></div> <div className="text-xs text-gray-400"></div>
@@ -188,6 +192,7 @@ export default function AccountSettingsDropdown() {
onClick={() => { onClick={() => {
setShowPasswordModal(false); setShowPasswordModal(false);
setError(''); setError('');
setSuccess('');
setOldPassword(''); setOldPassword('');
setNewPassword(''); setNewPassword('');
setConfirmPassword(''); setConfirmPassword('');

View File

@@ -9,7 +9,7 @@ import {
resolveBgmUrl, resolveBgmUrl,
resolveMediaUrl, resolveMediaUrl,
} from "@/shared/lib/media"; } from "@/shared/lib/media";
import { clampTitle } from "@/shared/lib/title"; import { clampTitle, clampSecondaryTitle, SECONDARY_TITLE_MAX_LENGTH } from "@/shared/lib/title";
import { useTitleInput } from "@/shared/hooks/useTitleInput"; import { useTitleInput } from "@/shared/hooks/useTitleInput";
import { useAuth } from "@/shared/contexts/AuthContext"; import { useAuth } from "@/shared/contexts/AuthContext";
import { useTask } from "@/shared/contexts/TaskContext"; import { useTask } from "@/shared/contexts/TaskContext";
@@ -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,11 +604,32 @@ export const useHomeController = () => {
} }
}, [titleStyles, selectedTitleStyleId, titleSizeLocked]); }, [titleStyles, selectedTitleStyleId, titleSizeLocked]);
useEffect(() => {
if (secondaryTitleSizeLocked || titleStyles.length === 0) return;
const active = titleStyles.find((s) => s.id === selectedSecondaryTitleStyleId)
|| titleStyles.find((s) => s.is_default)
|| titleStyles[0];
if (active?.font_size) {
setSecondaryTitleFontSize(active.font_size);
}
}, [titleStyles, selectedSecondaryTitleStyleId, secondaryTitleSizeLocked]);
// 移除重复的 BGM 持久化恢复逻辑 (已统一移动到 useHomePersistence 中) // 移除重复的 BGM 持久化恢复逻辑 (已统一移动到 useHomePersistence 中)
// useEffect(() => { ... }) // useEffect(() => { ... })
// 时间门控:页面加载后 1 秒内禁止所有列表自动滚动效果
// 防止持久化恢复 + 异步数据加载触发 scrollIntoView 导致移动端页面跳动
const scrollEffectsEnabled = useRef(false);
useEffect(() => { useEffect(() => {
if (!selectedBgmId) return; const timer = setTimeout(() => {
scrollEffectsEnabled.current = true;
}, 1000);
return () => clearTimeout(timer);
}, []);
// BGM 列表滚动
useEffect(() => {
if (!selectedBgmId || !scrollEffectsEnabled.current) return;
const container = bgmListContainerRef.current; const container = bgmListContainerRef.current;
const target = bgmItemRefs.current[selectedBgmId]; const target = bgmItemRefs.current[selectedBgmId];
if (container && target) { if (container && target) {
@@ -594,16 +637,10 @@ export const useHomeController = () => {
} }
}, [selectedBgmId, bgmList]); }, [selectedBgmId, bgmList]);
// 素材列表滚动:跳过首次恢复,仅用户主动操作时滚动 // 素材列表滚动
const materialScrollReady = useRef(false);
useEffect(() => { useEffect(() => {
const firstSelected = selectedMaterials[0]; const firstSelected = selectedMaterials[0];
if (!firstSelected) return; if (!firstSelected || !scrollEffectsEnabled.current) return;
if (!materialScrollReady.current) {
// 首次有选中素材时标记就绪,但不滚动(避免刷新后整页跳动)
materialScrollReady.current = true;
return;
}
const target = materialItemRefs.current[firstSelected]; const target = materialItemRefs.current[firstSelected];
if (target) { if (target) {
target.scrollIntoView({ block: "nearest", behavior: "smooth" }); target.scrollIntoView({ block: "nearest", behavior: "smooth" });
@@ -628,14 +665,9 @@ export const useHomeController = () => {
} }
}, [isRestored, bgmList, selectedBgmId, enableBgm, setSelectedBgmId]); }, [isRestored, bgmList, selectedBgmId, enableBgm, setSelectedBgmId]);
const videoScrollReady = useRef(false); // 视频列表滚动
useEffect(() => { useEffect(() => {
if (!selectedVideoId) return; if (!selectedVideoId || !scrollEffectsEnabled.current) return;
if (!videoScrollReady.current) {
videoScrollReady.current = true;
return;
}
const target = videoItemRefs.current[selectedVideoId]; const target = videoItemRefs.current[selectedVideoId];
if (target) { if (target) {
target.scrollIntoView({ block: "nearest", behavior: "smooth" }); target.scrollIntoView({ block: "nearest", behavior: "smooth" });
@@ -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) {
@@ -942,14 +978,28 @@ export const useHomeController = () => {
payload.title_font_size = Math.round(titleFontSize); payload.title_font_size = Math.round(titleFontSize);
} }
if (videoTitle.trim()) { if (videoTitle.trim() || videoSecondaryTitle.trim()) {
payload.title_display_mode = titleDisplayMode; payload.title_display_mode = titleDisplayMode;
if (titleDisplayMode === "short") { if (titleDisplayMode === "short") {
payload.title_duration = DEFAULT_SHORT_TITLE_DURATION; payload.title_duration = DEFAULT_SHORT_TITLE_DURATION;
} }
}
if (videoTitle.trim()) {
payload.title_top_margin = Math.round(titleTopMargin); payload.title_top_margin = Math.round(titleTopMargin);
} }
if (videoSecondaryTitle.trim()) {
payload.secondary_title = videoSecondaryTitle.trim();
if (selectedSecondaryTitleStyleId) {
payload.secondary_title_style_id = selectedSecondaryTitleStyleId;
}
if (secondaryTitleFontSize) {
payload.secondary_title_font_size = Math.round(secondaryTitleFontSize);
}
payload.secondary_title_top_margin = Math.round(secondaryTitleTopMargin);
}
payload.subtitle_bottom_margin = Math.round(subtitleBottomMargin); payload.subtitle_bottom_margin = Math.round(subtitleBottomMargin);
if (enableBgm && selectedBgmId) { if (enableBgm && selectedBgmId) {
@@ -1049,6 +1099,15 @@ export const useHomeController = () => {
titleFontSize, titleFontSize,
setTitleFontSize, setTitleFontSize,
setTitleSizeLocked, setTitleSizeLocked,
videoSecondaryTitle,
secondaryTitleInput,
selectedSecondaryTitleStyleId,
setSelectedSecondaryTitleStyleId,
secondaryTitleFontSize,
setSecondaryTitleFontSize,
setSecondaryTitleSizeLocked,
secondaryTitleTopMargin,
setSecondaryTitleTopMargin,
subtitleStyles, subtitleStyles,
selectedSubtitleStyleId, selectedSubtitleStyleId,
setSelectedSubtitleStyleId, setSelectedSubtitleStyleId,

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

@@ -43,7 +43,7 @@ export function BgmPanel({
return ( return (
<div className="bg-white/5 rounded-2xl p-6 border border-white/10 backdrop-blur-sm"> <div className="bg-white/5 rounded-2xl p-6 border border-white/10 backdrop-blur-sm">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-white flex items-center gap-2">🎵 </h2> <h2 className="text-lg font-semibold text-white flex items-center gap-2"></h2>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button <button
onClick={onRefresh} onClick={onRefresh}

View File

@@ -213,7 +213,7 @@ export function ClipTrimmer({
{/* Custom range track */} {/* Custom range track */}
<div <div
ref={trackRef} ref={trackRef}
className="relative h-8 cursor-pointer select-none touch-none" className="relative h-10 cursor-pointer select-none touch-none"
onPointerMove={handleTrackPointerMove} onPointerMove={handleTrackPointerMove}
onPointerUp={handleTrackPointerUp} onPointerUp={handleTrackPointerUp}
onPointerLeave={handleTrackPointerUp} onPointerLeave={handleTrackPointerUp}
@@ -242,7 +242,7 @@ export function ClipTrimmer({
{/* Start thumb */} {/* Start thumb */}
<div <div
onPointerDown={(e) => handleThumbPointerDown("start", e)} onPointerDown={(e) => handleThumbPointerDown("start", e)}
className="absolute top-1/2 -translate-y-1/2 -translate-x-1/2 w-4 h-4 rounded-full bg-purple-500 border-2 border-white shadow-lg cursor-grab active:cursor-grabbing hover:scale-110 transition-transform z-10" className="absolute top-1/2 -translate-y-1/2 -translate-x-1/2 w-5 h-5 rounded-full bg-purple-500 border-2 border-white shadow-lg cursor-grab active:cursor-grabbing hover:scale-110 transition-transform z-10"
style={{ left: `${startPct}%` }} style={{ left: `${startPct}%` }}
title={`起点: ${formatSec(sourceStart)}`} title={`起点: ${formatSec(sourceStart)}`}
/> />
@@ -250,7 +250,7 @@ export function ClipTrimmer({
{/* End thumb */} {/* End thumb */}
<div <div
onPointerDown={(e) => handleThumbPointerDown("end", e)} onPointerDown={(e) => handleThumbPointerDown("end", e)}
className="absolute top-1/2 -translate-y-1/2 -translate-x-1/2 w-4 h-4 rounded-full bg-pink-500 border-2 border-white shadow-lg cursor-grab active:cursor-grabbing hover:scale-110 transition-transform z-10" className="absolute top-1/2 -translate-y-1/2 -translate-x-1/2 w-5 h-5 rounded-full bg-pink-500 border-2 border-white shadow-lg cursor-grab active:cursor-grabbing hover:scale-110 transition-transform z-10"
style={{ left: `${endPct}%` }} style={{ left: `${endPct}%` }}
title={`终点: ${formatSec(effectiveEnd)}`} title={`终点: ${formatSec(effectiveEnd)}`}
/> />

View File

@@ -35,9 +35,13 @@ interface TitleStyleOption {
interface FloatingStylePreviewProps { interface FloatingStylePreviewProps {
onClose: () => void; onClose: () => void;
videoTitle: string; videoTitle: string;
videoSecondaryTitle: string;
titleStyles: TitleStyleOption[]; titleStyles: TitleStyleOption[];
selectedTitleStyleId: string; selectedTitleStyleId: string;
titleFontSize: number; titleFontSize: number;
selectedSecondaryTitleStyleId: string;
secondaryTitleFontSize: number;
secondaryTitleTopMargin: number;
subtitleStyles: SubtitleStyleOption[]; subtitleStyles: SubtitleStyleOption[];
selectedSubtitleStyleId: string; selectedSubtitleStyleId: string;
subtitleFontSize: number; subtitleFontSize: number;
@@ -52,13 +56,18 @@ interface FloatingStylePreviewProps {
} }
const DESKTOP_WIDTH = 280; const DESKTOP_WIDTH = 280;
const MOBILE_WIDTH = 160;
export function FloatingStylePreview({ export function FloatingStylePreview({
onClose, onClose,
videoTitle, videoTitle,
videoSecondaryTitle,
titleStyles, titleStyles,
selectedTitleStyleId, selectedTitleStyleId,
titleFontSize, titleFontSize,
selectedSecondaryTitleStyleId,
secondaryTitleFontSize,
secondaryTitleTopMargin,
subtitleStyles, subtitleStyles,
selectedSubtitleStyleId, selectedSubtitleStyleId,
subtitleFontSize, subtitleFontSize,
@@ -72,9 +81,7 @@ export function FloatingStylePreview({
previewBaseHeight, previewBaseHeight,
}: FloatingStylePreviewProps) { }: FloatingStylePreviewProps) {
const isMobile = typeof window !== "undefined" && window.innerWidth < 640; const isMobile = typeof window !== "undefined" && window.innerWidth < 640;
const windowWidth = isMobile const windowWidth = isMobile ? MOBILE_WIDTH : DESKTOP_WIDTH;
? Math.min(window.innerWidth - 32, 360)
: DESKTOP_WIDTH;
useEffect(() => { useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
@@ -126,15 +133,32 @@ export function FloatingStylePreview({
const scaledTitleTopMargin = Math.max(0, Math.round(titleTopMargin * responsiveScale)); const scaledTitleTopMargin = Math.max(0, Math.round(titleTopMargin * responsiveScale));
const scaledSubtitleBottomMargin = Math.max(0, Math.round(subtitleBottomMargin * responsiveScale)); const scaledSubtitleBottomMargin = Math.max(0, Math.round(subtitleBottomMargin * responsiveScale));
// 副标题样式
const activeSecondaryTitleStyle = titleStyles.find((s) => s.id === selectedSecondaryTitleStyleId)
|| activeTitleStyle;
const stColor = activeSecondaryTitleStyle?.color || "#FFFFFF";
const stStrokeColor = activeSecondaryTitleStyle?.stroke_color || "#000000";
const stStrokeSize = Math.max(1, Math.round((activeSecondaryTitleStyle?.stroke_size ?? 6) * responsiveScale));
const stLetterSpacing = Math.max(0, (activeSecondaryTitleStyle?.letter_spacing ?? 2) * responsiveScale);
const stFontWeight = activeSecondaryTitleStyle?.font_weight ?? 700;
const stFontFamilyName = `SecondaryTitlePreview-${activeSecondaryTitleStyle?.id || "default"}`;
const stFontUrl = activeSecondaryTitleStyle?.font_file
? resolveAssetUrl(`fonts/${activeSecondaryTitleStyle.font_file}`)
: null;
const scaledSecondaryTitleFontSize = Math.max(24, Math.round(secondaryTitleFontSize * responsiveScale));
const scaledSecondaryTitleTopMargin = Math.max(0, Math.round(secondaryTitleTopMargin * responsiveScale));
const previewSecondaryTitleText = videoSecondaryTitle.trim() || "";
const content = ( const content = (
<div <div
style={{ style={{
position: "fixed", position: "fixed",
left: "16px", ...(isMobile
top: "16px", ? { right: "12px", bottom: "12px" }
: { left: "16px", top: "16px" }),
width: `${windowWidth}px`, width: `${windowWidth}px`,
zIndex: 150, zIndex: 150,
maxHeight: "calc(100dvh - 32px)", maxHeight: isMobile ? "calc(50dvh)" : "calc(100dvh - 32px)",
overflow: "hidden", overflow: "hidden",
}} }}
className="rounded-xl border border-white/20 bg-gray-900/95 backdrop-blur-md shadow-2xl" className="rounded-xl border border-white/20 bg-gray-900/95 backdrop-blur-md shadow-2xl"
@@ -159,9 +183,10 @@ export function FloatingStylePreview({
className="relative overflow-hidden rounded-b-xl" className="relative overflow-hidden rounded-b-xl"
style={{ height: `${previewHeight}px` }} style={{ height: `${previewHeight}px` }}
> >
{(titleFontUrl || subtitleFontUrl) && ( {(titleFontUrl || subtitleFontUrl || stFontUrl) && (
<style>{` <style>{`
${titleFontUrl ? `@font-face { font-family: '${titleFontFamilyName}'; src: url('${titleFontUrl}') format('${getFontFormat(activeTitleStyle?.font_file)}'); font-weight: 400; font-style: normal; }` : ''} ${titleFontUrl ? `@font-face { font-family: '${titleFontFamilyName}'; src: url('${titleFontUrl}') format('${getFontFormat(activeTitleStyle?.font_file)}'); font-weight: 400; font-style: normal; }` : ''}
${stFontUrl && stFontUrl !== titleFontUrl ? `@font-face { font-family: '${stFontFamilyName}'; src: url('${stFontUrl}') format('${getFontFormat(activeSecondaryTitleStyle?.font_file)}'); font-weight: 400; font-style: normal; }` : ''}
${subtitleFontUrl ? `@font-face { font-family: '${subtitleFontFamilyName}'; src: url('${subtitleFontUrl}') format('${getFontFormat(activeSubtitleStyle?.font_file)}'); font-weight: 400; font-style: normal; }` : ''} ${subtitleFontUrl ? `@font-face { font-family: '${subtitleFontFamilyName}'; src: url('${subtitleFontUrl}') format('${getFontFormat(activeSubtitleStyle?.font_file)}'); font-weight: 400; font-style: normal; }` : ''}
`}</style> `}</style>
)} )}
@@ -182,24 +207,55 @@ export function FloatingStylePreview({
top: `${scaledTitleTopMargin}px`, top: `${scaledTitleTopMargin}px`,
left: 0, left: 0,
right: 0, right: 0,
color: titleColor, display: 'flex',
fontSize: `${scaledTitleFontSize}px`, flexDirection: 'column',
fontWeight: titleFontWeight, alignItems: 'center',
fontFamily: titleFontUrl
? `'${titleFontFamilyName}', "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans SC", sans-serif`
: '"PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans SC", sans-serif',
textShadow: buildTextShadow(titleStrokeColor, titleStrokeSize),
letterSpacing: `${titleLetterSpacing}px`,
lineHeight: 1.2,
whiteSpace: 'normal',
wordBreak: 'break-word',
overflowWrap: 'anywhere',
boxSizing: 'border-box',
opacity: videoTitle.trim() ? 1 : 0.7,
padding: '0 5%', padding: '0 5%',
boxSizing: 'border-box',
}} }}
> >
{previewTitleText} <div
style={{
color: titleColor,
fontSize: `${scaledTitleFontSize}px`,
fontWeight: titleFontWeight,
fontFamily: titleFontUrl
? `'${titleFontFamilyName}', "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans SC", sans-serif`
: '"PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans SC", sans-serif',
textShadow: buildTextShadow(titleStrokeColor, titleStrokeSize),
letterSpacing: `${titleLetterSpacing}px`,
lineHeight: 1.2,
whiteSpace: 'normal',
wordBreak: 'break-word',
overflowWrap: 'anywhere',
opacity: videoTitle.trim() ? 1 : 0.7,
}}
>
{previewTitleText}
</div>
{previewSecondaryTitleText && (
<div
style={{
marginTop: `${scaledSecondaryTitleTopMargin}px`,
color: stColor,
fontSize: `${scaledSecondaryTitleFontSize}px`,
fontWeight: stFontWeight,
fontFamily: stFontUrl && stFontUrl !== titleFontUrl
? `'${stFontFamilyName}', "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans SC", sans-serif`
: titleFontUrl
? `'${titleFontFamilyName}', "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans SC", sans-serif`
: '"PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans SC", sans-serif',
textShadow: buildTextShadow(stStrokeColor, stStrokeSize),
letterSpacing: `${stLetterSpacing}px`,
lineHeight: 1.2,
whiteSpace: 'normal',
wordBreak: 'break-word',
overflowWrap: 'anywhere',
}}
>
{previewSecondaryTitleText}
</div>
)}
</div> </div>
<div <div

View File

@@ -23,6 +23,7 @@ interface GeneratedAudiosPanelProps {
speed: number; speed: number;
onSpeedChange: (speed: number) => void; onSpeedChange: (speed: number) => void;
ttsMode: string; ttsMode: string;
embedded?: boolean;
} }
export function GeneratedAudiosPanel({ export function GeneratedAudiosPanel({
@@ -40,6 +41,7 @@ export function GeneratedAudiosPanel({
speed, speed,
onSpeedChange, onSpeedChange,
ttsMode, ttsMode,
embedded = false,
}: GeneratedAudiosPanelProps) { }: GeneratedAudiosPanelProps) {
const [editingId, setEditingId] = useState<string | null>(null); const [editingId, setEditingId] = useState<string | null>(null);
const [editName, setEditName] = useState(""); const [editName, setEditName] = useState("");
@@ -123,64 +125,124 @@ export function GeneratedAudiosPanel({
] as const; ] as const;
const currentSpeedLabel = speedOptions.find((o) => o.value === speed)?.label ?? "正常"; const currentSpeedLabel = speedOptions.find((o) => o.value === speed)?.label ?? "正常";
return ( const content = (
<div className="bg-white/5 rounded-2xl p-4 sm:p-6 border border-white/10 backdrop-blur-sm relative z-10"> <>
<div className="flex justify-between items-center gap-2 mb-4"> {embedded ? (
<h2 className="text-base sm:text-lg font-semibold text-white flex items-center gap-2 whitespace-nowrap"> <>
<Mic className="h-4 w-4 text-purple-400" /> {/* Row 1: 语速 + 生成配音 (right-aligned) */}
<div className="flex justify-end items-center gap-1.5 mb-3">
</h2> {ttsMode === "voiceclone" && (
<div className="flex gap-1.5"> <div ref={speedRef} className="relative">
{/* 语速下拉 (仅声音克隆模式) */} <button
{ttsMode === "voiceclone" && ( onClick={() => setSpeedOpen((v) => !v)}
<div ref={speedRef} className="relative"> className="px-2 py-1 text-xs bg-white/10 hover:bg-white/20 rounded text-gray-300 whitespace-nowrap flex items-center gap-1 transition-all"
<button >
onClick={() => setSpeedOpen((v) => !v)} : {currentSpeedLabel}
className="px-2 py-1 text-xs bg-white/10 hover:bg-white/20 rounded text-gray-300 whitespace-nowrap flex items-center gap-1 transition-all" <ChevronDown className={`h-3 w-3 transition-transform ${speedOpen ? "rotate-180" : ""}`} />
> </button>
: {currentSpeedLabel} {speedOpen && (
<ChevronDown className={`h-3 w-3 transition-transform ${speedOpen ? "rotate-180" : ""}`} /> <div className="absolute right-0 top-full mt-1 bg-gray-800 border border-white/20 rounded-lg shadow-xl py-1 z-50 min-w-[80px]">
</button> {speedOptions.map((opt) => (
{speedOpen && ( <button
<div className="absolute right-0 top-full mt-1 bg-gray-800 border border-white/20 rounded-lg shadow-xl py-1 z-50 min-w-[80px]"> key={opt.value}
{speedOptions.map((opt) => ( onClick={() => { onSpeedChange(opt.value); setSpeedOpen(false); }}
<button className={`w-full text-left px-3 py-1.5 text-xs transition-colors ${
key={opt.value} speed === opt.value
onClick={() => { onSpeedChange(opt.value); setSpeedOpen(false); }} ? "bg-purple-600/40 text-purple-200"
className={`w-full text-left px-3 py-1.5 text-xs transition-colors ${ : "text-gray-300 hover:bg-white/10"
speed === opt.value }`}
? "bg-purple-600/40 text-purple-200" >
: "text-gray-300 hover:bg-white/10" {opt.label}
}`} </button>
> ))}
{opt.label} </div>
</button> )}
))} </div>
</div> )}
)} <button
</div> onClick={onGenerateAudio}
)} disabled={isGeneratingAudio || !canGenerate}
<button title={missingRefAudio ? "请先选择参考音频" : !hasText ? "请先输入文案" : ""}
onClick={onGenerateAudio} className={`px-4 py-2 text-sm font-medium rounded-lg transition-all whitespace-nowrap flex items-center gap-1.5 shadow-sm ${
disabled={isGeneratingAudio || !canGenerate} isGeneratingAudio || !canGenerate
title={missingRefAudio ? "请先选择参考音频" : !hasText ? "请先输入文案" : ""} ? "bg-gray-600 cursor-not-allowed text-gray-400"
className={`px-2 py-1 text-xs rounded transition-all whitespace-nowrap flex items-center gap-1 ${ : "bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-700 hover:to-pink-700 text-white hover:shadow-md"
isGeneratingAudio || !canGenerate }`}
? "bg-gray-600 cursor-not-allowed text-gray-400" >
: "bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-700 hover:to-pink-700 text-white" <Mic className="h-4 w-4" />
}`}
> </button>
<Mic className="h-3.5 w-3.5" /> </div>
{/* Row 2: 配音列表 + 刷新 */}
</button> <div className="flex justify-between items-center mb-3">
<button <h3 className="text-sm font-medium text-gray-400"></h3>
onClick={onRefresh} <button
className="px-2 py-1 text-xs bg-white/10 hover:bg-white/20 rounded text-gray-300 whitespace-nowrap flex items-center gap-1" onClick={onRefresh}
> className="px-2 py-1 text-xs bg-white/10 hover:bg-white/20 rounded text-gray-300 whitespace-nowrap flex items-center gap-1"
<RefreshCw className="h-3.5 w-3.5" /> >
</button> <RefreshCw className="h-3.5 w-3.5" />
</button>
</div>
</>
) : (
<div className="flex justify-between items-center gap-2 mb-4">
<h2 className="text-base sm:text-lg font-semibold text-white flex items-center gap-2 whitespace-nowrap">
<Mic className="h-4 w-4 text-purple-400" />
</h2>
<div className="flex gap-1.5">
{ttsMode === "voiceclone" && (
<div ref={speedRef} className="relative">
<button
onClick={() => setSpeedOpen((v) => !v)}
className="px-2 py-1 text-xs bg-white/10 hover:bg-white/20 rounded text-gray-300 whitespace-nowrap flex items-center gap-1 transition-all"
>
: {currentSpeedLabel}
<ChevronDown className={`h-3 w-3 transition-transform ${speedOpen ? "rotate-180" : ""}`} />
</button>
{speedOpen && (
<div className="absolute right-0 top-full mt-1 bg-gray-800 border border-white/20 rounded-lg shadow-xl py-1 z-50 min-w-[80px]">
{speedOptions.map((opt) => (
<button
key={opt.value}
onClick={() => { onSpeedChange(opt.value); setSpeedOpen(false); }}
className={`w-full text-left px-3 py-1.5 text-xs transition-colors ${
speed === opt.value
? "bg-purple-600/40 text-purple-200"
: "text-gray-300 hover:bg-white/10"
}`}
>
{opt.label}
</button>
))}
</div>
)}
</div>
)}
<button
onClick={onGenerateAudio}
disabled={isGeneratingAudio || !canGenerate}
title={missingRefAudio ? "请先选择参考音频" : !hasText ? "请先输入文案" : ""}
className={`px-4 py-2 text-sm font-medium rounded-lg transition-all whitespace-nowrap flex items-center gap-1.5 shadow-sm ${
isGeneratingAudio || !canGenerate
? "bg-gray-600 cursor-not-allowed text-gray-400"
: "bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-700 hover:to-pink-700 text-white hover:shadow-md"
}`}
>
<Mic className="h-4 w-4" />
</button>
<button
onClick={onRefresh}
className="px-2 py-1 text-xs bg-white/10 hover:bg-white/20 rounded text-gray-300 whitespace-nowrap flex items-center gap-1"
>
<RefreshCw className="h-3.5 w-3.5" />
</button>
</div>
</div> </div>
</div> )}
{/* 缺少参考音频提示 */} {/* 缺少参考音频提示 */}
{missingRefAudio && ( {missingRefAudio && (
@@ -250,7 +312,7 @@ export function GeneratedAudiosPanel({
<div className="text-white text-sm truncate">{audio.name}</div> <div className="text-white text-sm truncate">{audio.name}</div>
<div className="text-gray-400 text-xs">{audio.duration_sec.toFixed(1)}s</div> <div className="text-gray-400 text-xs">{audio.duration_sec.toFixed(1)}s</div>
</div> </div>
<div className="flex items-center gap-1 pl-2 opacity-0 group-hover:opacity-100 transition-opacity"> <div className="flex items-center gap-1 pl-2 opacity-40 group-hover:opacity-100 transition-opacity">
<button <button
onClick={(e) => togglePlay(audio, e)} onClick={(e) => togglePlay(audio, e)}
className="p-1 text-gray-500 hover:text-purple-400 transition-colors" className="p-1 text-gray-500 hover:text-purple-400 transition-colors"
@@ -287,7 +349,14 @@ export function GeneratedAudiosPanel({
})} })}
</div> </div>
)} )}
</>
);
if (embedded) return content;
return (
<div className="bg-white/5 rounded-2xl p-4 sm:p-6 border border-white/10 backdrop-blur-sm relative z-10">
{content}
</div> </div>
); );
} }

View File

@@ -16,6 +16,7 @@ interface HistoryListProps {
onRefresh: () => void; onRefresh: () => void;
registerVideoRef: (id: string, element: HTMLDivElement | null) => void; registerVideoRef: (id: string, element: HTMLDivElement | null) => void;
formatDate: (timestamp: number) => string; formatDate: (timestamp: number) => string;
embedded?: boolean;
} }
export function HistoryList({ export function HistoryList({
@@ -26,19 +27,22 @@ export function HistoryList({
onRefresh, onRefresh,
registerVideoRef, registerVideoRef,
formatDate, formatDate,
embedded = false,
}: HistoryListProps) { }: HistoryListProps) {
return ( const content = (
<div className="bg-white/5 rounded-2xl p-6 border border-white/10 backdrop-blur-sm"> <>
<div className="flex justify-between items-center mb-4"> {!embedded && (
<h2 className="text-lg font-semibold text-white flex items-center gap-2">📂 </h2> <div className="flex justify-between items-center mb-4">
<button <h2 className="text-lg font-semibold text-white flex items-center gap-2"></h2>
onClick={onRefresh} <button
className="px-3 py-1 text-xs bg-white/10 hover:bg-white/20 rounded text-gray-300 flex items-center gap-1" onClick={onRefresh}
> className="px-3 py-1 text-xs bg-white/10 hover:bg-white/20 rounded text-gray-300 flex items-center gap-1"
<RefreshCw className="h-3.5 w-3.5" /> >
<RefreshCw className="h-3.5 w-3.5" />
</button>
</div> </button>
</div>
)}
{generatedVideos.length === 0 ? ( {generatedVideos.length === 0 ? (
<div className="text-center py-4 text-gray-500"> <div className="text-center py-4 text-gray-500">
<p></p> <p></p>
@@ -66,7 +70,7 @@ export function HistoryList({
e.stopPropagation(); e.stopPropagation();
onDeleteVideo(v.id); onDeleteVideo(v.id);
}} }}
className="p-1 text-gray-500 hover:text-red-400 opacity-0 group-hover:opacity-100 transition-opacity" className="p-1 text-gray-500 hover:text-red-400 opacity-40 group-hover:opacity-100 transition-opacity"
title="删除视频" title="删除视频"
> >
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
@@ -75,6 +79,14 @@ export function HistoryList({
))} ))}
</div> </div>
)} )}
</>
);
if (embedded) return content;
return (
<div className="bg-white/5 rounded-2xl p-6 border border-white/10 backdrop-blur-sm">
{content}
</div> </div>
); );
} }

View File

@@ -2,6 +2,7 @@
import { useEffect, useMemo } from "react"; import { useEffect, useMemo } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { RefreshCw } from "lucide-react";
import VideoPreviewModal from "@/components/VideoPreviewModal"; import VideoPreviewModal from "@/components/VideoPreviewModal";
import ScriptExtractionModal from "./ScriptExtractionModal"; import ScriptExtractionModal from "./ScriptExtractionModal";
import { useHomeController } from "@/features/home/model/useHomeController"; import { useHomeController } from "@/features/home/model/useHomeController";
@@ -70,6 +71,15 @@ export function HomePage() {
titleFontSize, titleFontSize,
setTitleFontSize, setTitleFontSize,
setTitleSizeLocked, setTitleSizeLocked,
videoSecondaryTitle,
secondaryTitleInput,
selectedSecondaryTitleStyleId,
setSelectedSecondaryTitleStyleId,
secondaryTitleFontSize,
setSecondaryTitleFontSize,
setSecondaryTitleSizeLocked,
secondaryTitleTopMargin,
setSecondaryTitleTopMargin,
subtitleStyles, subtitleStyles,
selectedSubtitleStyleId, selectedSubtitleStyleId,
setSelectedSubtitleStyleId, setSelectedSubtitleStyleId,
@@ -170,7 +180,15 @@ export function HomePage() {
useEffect(() => { useEffect(() => {
if (typeof window === "undefined") return; if (typeof window === "undefined") return;
if ("scrollRestoration" in history) {
history.scrollRestoration = "manual";
}
window.scrollTo({ top: 0, left: 0, behavior: "auto" }); window.scrollTo({ top: 0, left: 0, behavior: "auto" });
// 兜底:等所有恢复 effect + 异步数据加载 settle 后再次强制回顶部
const timer = setTimeout(() => {
window.scrollTo({ top: 0, left: 0, behavior: "auto" });
}, 200);
return () => clearTimeout(timer);
}, []); }, []);
const clipTrimmerSegment = useMemo( const clipTrimmerSegment = useMemo(
@@ -192,7 +210,7 @@ export function HomePage() {
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* 左侧: 输入区域 */} {/* 左侧: 输入区域 */}
<div className="space-y-6"> <div className="space-y-6">
{/* 1. 文案输入 */} {/* 一、文案提取与编辑 */}
<ScriptEditor <ScriptEditor
text={text} text={text}
onChangeText={setText} onChangeText={setText}
@@ -209,7 +227,7 @@ export function HomePage() {
onDeleteScript={deleteSavedScript} onDeleteScript={deleteSavedScript}
/> />
{/* 2. 标题字幕设置 */} {/* 二、标题字幕 */}
<TitleSubtitlePanel <TitleSubtitlePanel
showStylePreview={showStylePreview} showStylePreview={showStylePreview}
onTogglePreview={() => setShowStylePreview((prev) => !prev)} onTogglePreview={() => setShowStylePreview((prev) => !prev)}
@@ -217,6 +235,10 @@ export function HomePage() {
onTitleChange={titleInput.handleChange} onTitleChange={titleInput.handleChange}
onTitleCompositionStart={titleInput.handleCompositionStart} onTitleCompositionStart={titleInput.handleCompositionStart}
onTitleCompositionEnd={titleInput.handleCompositionEnd} onTitleCompositionEnd={titleInput.handleCompositionEnd}
videoSecondaryTitle={videoSecondaryTitle}
onSecondaryTitleChange={secondaryTitleInput.handleChange}
onSecondaryTitleCompositionStart={secondaryTitleInput.handleCompositionStart}
onSecondaryTitleCompositionEnd={secondaryTitleInput.handleCompositionEnd}
titleStyles={titleStyles} titleStyles={titleStyles}
selectedTitleStyleId={selectedTitleStyleId} selectedTitleStyleId={selectedTitleStyleId}
onSelectTitleStyle={setSelectedTitleStyleId} onSelectTitleStyle={setSelectedTitleStyleId}
@@ -225,6 +247,15 @@ export function HomePage() {
setTitleFontSize(value); setTitleFontSize(value);
setTitleSizeLocked(true); setTitleSizeLocked(true);
}} }}
selectedSecondaryTitleStyleId={selectedSecondaryTitleStyleId}
onSelectSecondaryTitleStyle={setSelectedSecondaryTitleStyleId}
secondaryTitleFontSize={secondaryTitleFontSize}
onSecondaryTitleFontSizeChange={(value) => {
setSecondaryTitleFontSize(value);
setSecondaryTitleSizeLocked(true);
}}
secondaryTitleTopMargin={secondaryTitleTopMargin}
onSecondaryTitleTopMarginChange={setSecondaryTitleTopMargin}
subtitleStyles={subtitleStyles} subtitleStyles={subtitleStyles}
selectedSubtitleStyleId={selectedSubtitleStyleId} selectedSubtitleStyleId={selectedSubtitleStyleId}
onSelectSubtitleStyle={setSelectedSubtitleStyleId} onSelectSubtitleStyle={setSelectedSubtitleStyleId}
@@ -246,65 +277,77 @@ export function HomePage() {
previewBaseHeight={outputAspectRatio === "16:9" ? 1080 : 1920} previewBaseHeight={outputAspectRatio === "16:9" ? 1080 : 1920}
/> />
{/* 3. 配音方式选择 */} {/* 三、配音 */}
<VoiceSelector <div className="bg-white/5 rounded-2xl p-4 sm:p-6 border border-white/10 backdrop-blur-sm">
ttsMode={ttsMode} <h2 className="text-base sm:text-lg font-semibold text-white mb-4">
onSelectTtsMode={setTtsMode}
voices={voices} </h2>
voice={voice} <h3 className="text-sm font-medium text-gray-400 mb-3"></h3>
onSelectVoice={setVoice} <VoiceSelector
voiceCloneSlot={( embedded
<RefAudioPanel ttsMode={ttsMode}
refAudios={refAudios} onSelectTtsMode={setTtsMode}
selectedRefAudio={selectedRefAudio} voices={voices}
onSelectRefAudio={handleSelectRefAudio} voice={voice}
isUploadingRef={isUploadingRef} onSelectVoice={setVoice}
uploadRefError={uploadRefError} voiceCloneSlot={(
onClearUploadRefError={() => setUploadRefError(null)} <RefAudioPanel
onUploadRefAudio={uploadRefAudio} refAudios={refAudios}
onFetchRefAudios={fetchRefAudios} selectedRefAudio={selectedRefAudio}
playingAudioId={playingAudioId} onSelectRefAudio={handleSelectRefAudio}
onTogglePlayPreview={togglePlayPreview} isUploadingRef={isUploadingRef}
editingAudioId={editingAudioId} uploadRefError={uploadRefError}
editName={editName} onClearUploadRefError={() => setUploadRefError(null)}
onEditNameChange={setEditName} onUploadRefAudio={uploadRefAudio}
onStartEditing={startEditing} onFetchRefAudios={fetchRefAudios}
onSaveEditing={saveEditing} playingAudioId={playingAudioId}
onCancelEditing={cancelEditing} onTogglePlayPreview={togglePlayPreview}
onDeleteRefAudio={deleteRefAudio} editingAudioId={editingAudioId}
onRetranscribe={retranscribeRefAudio} editName={editName}
retranscribingId={retranscribingId} onEditNameChange={setEditName}
recordedBlob={recordedBlob} onStartEditing={startEditing}
isRecording={isRecording} onSaveEditing={saveEditing}
recordingTime={recordingTime} onCancelEditing={cancelEditing}
onStartRecording={startRecording} onDeleteRefAudio={deleteRefAudio}
onStopRecording={stopRecording} onRetranscribe={retranscribeRefAudio}
onUseRecording={useRecording} retranscribingId={retranscribingId}
formatRecordingTime={formatRecordingTime} recordedBlob={recordedBlob}
/> isRecording={isRecording}
)} recordingTime={recordingTime}
/> onStartRecording={startRecording}
onStopRecording={stopRecording}
onUseRecording={useRecording}
formatRecordingTime={formatRecordingTime}
/>
)}
/>
<div className="border-t border-white/10 my-4" />
<GeneratedAudiosPanel
embedded
generatedAudios={generatedAudios}
selectedAudioId={selectedAudioId}
isGeneratingAudio={isGeneratingAudio}
audioTask={audioTask}
onGenerateAudio={handleGenerateAudio}
onRefresh={() => fetchGeneratedAudios()}
onSelectAudio={selectAudio}
onDeleteAudio={deleteAudio}
onRenameAudio={renameAudio}
hasText={!!text.trim()}
missingRefAudio={ttsMode === "voiceclone" && !selectedRefAudio}
speed={speed}
onSpeedChange={setSpeed}
ttsMode={ttsMode}
/>
</div>
{/* 4. 配音列表 */} {/* 四、素材编辑 */}
<GeneratedAudiosPanel <div className="bg-white/5 rounded-2xl p-4 sm:p-6 border border-white/10 backdrop-blur-sm">
generatedAudios={generatedAudios} <h2 className="text-base sm:text-lg font-semibold text-white mb-4">
selectedAudioId={selectedAudioId}
isGeneratingAudio={isGeneratingAudio} </h2>
audioTask={audioTask} <MaterialSelector
onGenerateAudio={handleGenerateAudio} embedded
onRefresh={() => fetchGeneratedAudios()}
onSelectAudio={selectAudio}
onDeleteAudio={deleteAudio}
onRenameAudio={renameAudio}
hasText={!!text.trim()}
missingRefAudio={ttsMode === "voiceclone" && !selectedRefAudio}
speed={speed}
onSpeedChange={setSpeed}
ttsMode={ttsMode}
/>
{/* 5. 视频素材 */}
<MaterialSelector
materials={materials} materials={materials}
selectedMaterials={selectedMaterials} selectedMaterials={selectedMaterials}
isFetching={isFetching} isFetching={isFetching}
@@ -328,32 +371,33 @@ export function HomePage() {
onClearUploadError={() => setUploadError(null)} onClearUploadError={() => setUploadError(null)}
registerMaterialRef={registerMaterialRef} registerMaterialRef={registerMaterialRef}
/> />
<div className="border-t border-white/10 my-4" />
{/* 5.5 时间轴编辑器 — 未选配音/素材时模糊遮挡 */} <div className="relative">
<div className="relative"> {(!selectedAudio || selectedMaterials.length === 0) && (
{(!selectedAudio || selectedMaterials.length === 0) && ( <div className="absolute inset-0 bg-black/50 backdrop-blur-sm rounded-xl flex items-center justify-center z-10">
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm rounded-2xl flex items-center justify-center z-10"> <p className="text-gray-400">
<p className="text-gray-400"> {!selectedAudio ? "请先生成并选中配音" : "请先选择素材"}
{!selectedAudio ? "请先生成并选中配音" : "请先选择素材"} </p>
</p> </div>
</div> )}
)} <TimelineEditor
<TimelineEditor embedded
audioDuration={selectedAudio?.duration_sec ?? 0} audioDuration={selectedAudio?.duration_sec ?? 0}
audioUrl={selectedAudio ? (resolveMediaUrl(selectedAudio.path) || "") : ""} audioUrl={selectedAudio ? (resolveMediaUrl(selectedAudio.path) || "") : ""}
segments={timelineSegments} segments={timelineSegments}
materials={materials} materials={materials}
outputAspectRatio={outputAspectRatio} outputAspectRatio={outputAspectRatio}
onOutputAspectRatioChange={setOutputAspectRatio} onOutputAspectRatioChange={setOutputAspectRatio}
onReorderSegment={reorderSegments} onReorderSegment={reorderSegments}
onClickSegment={(seg) => { onClickSegment={(seg) => {
setClipTrimmerSegmentId(seg.id); setClipTrimmerSegmentId(seg.id);
setClipTrimmerOpen(true); setClipTrimmerOpen(true);
}} }}
/> />
</div>
</div> </div>
{/* 6. 背景音乐 */} {/* 背景音乐 (不编号) */}
<BgmPanel <BgmPanel
bgmList={bgmList} bgmList={bgmList}
bgmLoading={bgmLoading} bgmLoading={bgmLoading}
@@ -371,7 +415,7 @@ export function HomePage() {
registerBgmItemRef={registerBgmItemRef} registerBgmItemRef={registerBgmItemRef}
/> />
{/* 7. 生成按钮 */} {/* 生成按钮 (不编号) */}
<GenerateActionBar <GenerateActionBar
isGenerating={isGenerating} isGenerating={isGenerating}
progress={currentTask?.progress || 0} progress={currentTask?.progress || 0}
@@ -381,23 +425,59 @@ export function HomePage() {
/> />
</div> </div>
{/* 右侧: 预览区域 */} {/* 右侧: 作品区域 */}
<div className="space-y-6"> <div className="space-y-6">
<PreviewPanel {/* 生成进度(在作品卡片上方) */}
currentTask={currentTask} {currentTask && isGenerating && (
isGenerating={isGenerating} <div className="bg-white/5 rounded-2xl p-4 sm:p-6 border border-purple-500/30 backdrop-blur-sm">
generatedVideo={generatedVideo} <div className="space-y-3">
/> <div className="flex justify-between text-sm text-purple-300 mb-1">
<span>AI生成中...</span>
<HistoryList <span>{currentTask.progress || 0}%</span>
generatedVideos={generatedVideos} </div>
selectedVideoId={selectedVideoId} <div className="h-3 bg-black/30 rounded-full overflow-hidden">
onSelectVideo={handleSelectVideo} <div
onDeleteVideo={deleteVideo} className="h-full bg-gradient-to-r from-purple-500 to-pink-500 transition-all duration-300"
onRefresh={() => fetchGeneratedVideos()} style={{ width: `${currentTask.progress || 0}%` }}
registerVideoRef={registerVideoRef} />
formatDate={formatDate} </div>
/> </div>
</div>
)}
{/* 六、作品 */}
<div className="bg-white/5 rounded-2xl p-4 sm:p-6 border border-white/10 backdrop-blur-sm">
<h2 className="text-base sm:text-lg font-semibold text-white mb-4">
</h2>
<div className="flex justify-between items-center mb-3">
<h3 className="text-sm font-medium text-gray-400"></h3>
<button
onClick={() => fetchGeneratedVideos()}
className="px-2 py-1 text-xs bg-white/10 hover:bg-white/20 rounded text-gray-300 flex items-center gap-1"
>
<RefreshCw className="h-3.5 w-3.5" />
</button>
</div>
<HistoryList
embedded
generatedVideos={generatedVideos}
selectedVideoId={selectedVideoId}
onSelectVideo={handleSelectVideo}
onDeleteVideo={deleteVideo}
onRefresh={() => fetchGeneratedVideos()}
registerVideoRef={registerVideoRef}
formatDate={formatDate}
/>
<div className="border-t border-white/10 my-4" />
<h3 className="text-sm font-medium text-gray-400 mb-3"></h3>
<PreviewPanel
embedded
currentTask={null}
isGenerating={false}
generatedVideo={generatedVideo}
/>
</div>
</div> </div>
</div> </div>
</main> </main>

View File

@@ -1,4 +1,4 @@
import { type ChangeEvent, type MouseEvent } from "react"; import { type ChangeEvent, type MouseEvent, useMemo } from "react";
import { Upload, RefreshCw, Eye, Trash2, X, Pencil, Check } from "lucide-react"; import { Upload, RefreshCw, Eye, Trash2, X, Pencil, Check } from "lucide-react";
import type { Material } from "@/shared/types/material"; import type { Material } from "@/shared/types/material";
@@ -25,6 +25,7 @@ interface MaterialSelectorProps {
onDeleteMaterial: (id: string) => void; onDeleteMaterial: (id: string) => void;
onClearUploadError: () => void; onClearUploadError: () => void;
registerMaterialRef: (id: string, element: HTMLDivElement | null) => void; registerMaterialRef: (id: string, element: HTMLDivElement | null) => void;
embedded?: boolean;
} }
export function MaterialSelector({ export function MaterialSelector({
@@ -50,19 +51,27 @@ export function MaterialSelector({
onDeleteMaterial, onDeleteMaterial,
onClearUploadError, onClearUploadError,
registerMaterialRef, registerMaterialRef,
embedded = false,
}: MaterialSelectorProps) { }: MaterialSelectorProps) {
const selectedSet = new Set(selectedMaterials); const selectedSet = useMemo(() => new Set(selectedMaterials), [selectedMaterials]);
const isFull = selectedMaterials.length >= 4; const isFull = selectedMaterials.length >= 4;
return ( const content = (
<div className="bg-white/5 rounded-2xl p-4 sm:p-6 border border-white/10 backdrop-blur-sm"> <>
<div className="flex justify-between items-center gap-2 mb-4"> <div className="flex justify-between items-center gap-2 mb-4">
<h2 className="text-base sm:text-lg font-semibold text-white flex items-center gap-2 whitespace-nowrap"> {!embedded ? (
📹 <h2 className="text-base sm:text-lg font-semibold text-white flex items-center gap-2 min-w-0">
<span className="ml-1 text-[11px] sm:text-xs text-gray-400/90 font-normal"> <span className="shrink-0"></span>
(4) <span className="text-[11px] sm:text-xs text-gray-400/90 font-normal truncate">
</span> (4)
</h2> </span>
</h2>
) : (
<h3 className="text-sm font-medium text-gray-400 min-w-0">
<span className="shrink-0"></span>
<span className="ml-1 text-[11px] text-gray-400/90 font-normal hidden sm:inline">(4)</span>
</h3>
)}
<div className="flex gap-1.5"> <div className="flex gap-1.5">
<input <input
type="file" type="file"
@@ -94,7 +103,7 @@ export function MaterialSelector({
{isUploading && ( {isUploading && (
<div className="mb-4 p-4 bg-purple-500/10 rounded-xl border border-purple-500/30"> <div className="mb-4 p-4 bg-purple-500/10 rounded-xl border border-purple-500/30">
<div className="flex justify-between text-sm text-purple-300 mb-2"> <div className="flex justify-between text-sm text-purple-300 mb-2">
<span>📤 ...</span> <span>...</span>
<span>{uploadProgress}%</span> <span>{uploadProgress}%</span>
</div> </div>
<div className="h-2 bg-black/30 rounded-full overflow-hidden"> <div className="h-2 bg-black/30 rounded-full overflow-hidden">
@@ -108,7 +117,7 @@ export function MaterialSelector({
{uploadError && ( {uploadError && (
<div className="mb-4 p-4 bg-red-500/20 text-red-200 rounded-xl text-sm flex justify-between items-center"> <div className="mb-4 p-4 bg-red-500/20 text-red-200 rounded-xl text-sm flex justify-between items-center">
<span> {uploadError}</span> <span>{uploadError}</span>
<button onClick={onClearUploadError} className="text-red-300 hover:text-white"> <button onClick={onClearUploadError} className="text-red-300 hover:text-white">
<X className="h-3.5 w-3.5" /> <X className="h-3.5 w-3.5" />
</button> </button>
@@ -138,7 +147,7 @@ export function MaterialSelector({
<div className="text-5xl mb-4">📁</div> <div className="text-5xl mb-4">📁</div>
<p></p> <p></p>
<p className="text-sm mt-2"> <p className="text-sm mt-2">
📤
</p> </p>
</div> </div>
) : ( ) : (
@@ -183,7 +192,7 @@ export function MaterialSelector({
</button> </button>
</div> </div>
) : ( ) : (
<button onClick={() => onToggleMaterial(m.id)} className="flex-1 text-left flex items-center gap-2"> <button onClick={() => onToggleMaterial(m.id)} disabled={isFull && !isSelected} className="flex-1 text-left flex items-center gap-2">
{/* 复选框 */} {/* 复选框 */}
<span <span
className={`flex-shrink-0 w-4 h-4 rounded border flex items-center justify-center text-[10px] ${isSelected className={`flex-shrink-0 w-4 h-4 rounded border flex items-center justify-center text-[10px] ${isSelected
@@ -207,7 +216,7 @@ export function MaterialSelector({
onPreviewMaterial(m.path); onPreviewMaterial(m.path);
} }
}} }}
className="p-1 text-gray-500 hover:text-white opacity-0 group-hover:opacity-100 transition-opacity" className="p-1 text-gray-500 hover:text-white opacity-40 group-hover:opacity-100 transition-opacity"
title="预览视频" title="预览视频"
> >
<Eye className="h-4 w-4" /> <Eye className="h-4 w-4" />
@@ -215,7 +224,7 @@ export function MaterialSelector({
{editingMaterialId !== m.id && ( {editingMaterialId !== m.id && (
<button <button
onClick={(e) => onStartEditing(m, e)} onClick={(e) => onStartEditing(m, e)}
className="p-1 text-gray-500 hover:text-white opacity-0 group-hover:opacity-100 transition-opacity" className="p-1 text-gray-500 hover:text-white opacity-40 group-hover:opacity-100 transition-opacity"
title="重命名" title="重命名"
> >
<Pencil className="h-4 w-4" /> <Pencil className="h-4 w-4" />
@@ -226,7 +235,7 @@ export function MaterialSelector({
e.stopPropagation(); e.stopPropagation();
onDeleteMaterial(m.id); onDeleteMaterial(m.id);
}} }}
className="p-1 text-gray-500 hover:text-red-400 opacity-0 group-hover:opacity-100 transition-opacity" className="p-1 text-gray-500 hover:text-red-400 opacity-40 group-hover:opacity-100 transition-opacity"
title="删除素材" title="删除素材"
> >
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
@@ -237,6 +246,14 @@ export function MaterialSelector({
})} })}
</div> </div>
)} )}
</>
);
if (embedded) return content;
return (
<div className="bg-white/5 rounded-2xl p-4 sm:p-6 border border-white/10 backdrop-blur-sm">
{content}
</div> </div>
); );
} }

View File

@@ -12,18 +12,20 @@ interface PreviewPanelProps {
currentTask: Task | null; currentTask: Task | null;
isGenerating: boolean; isGenerating: boolean;
generatedVideo: string | null; generatedVideo: string | null;
embedded?: boolean;
} }
export function PreviewPanel({ export function PreviewPanel({
currentTask, currentTask,
isGenerating, isGenerating,
generatedVideo, generatedVideo,
embedded = false,
}: PreviewPanelProps) { }: PreviewPanelProps) {
return ( const content = (
<> <>
{currentTask && isGenerating && ( {currentTask && isGenerating && (
<div className="bg-white/5 rounded-2xl p-6 border border-white/10 backdrop-blur-sm"> <div className={embedded ? "mb-4" : "bg-white/5 rounded-2xl p-6 border border-white/10 backdrop-blur-sm"}>
<h2 className="text-lg font-semibold text-white mb-4"> </h2> {!embedded && <h2 className="text-lg font-semibold text-white mb-4"></h2>}
<div className="space-y-3"> <div className="space-y-3">
<div className="h-3 bg-black/30 rounded-full overflow-hidden"> <div className="h-3 bg-black/30 rounded-full overflow-hidden">
<div <div
@@ -36,8 +38,8 @@ export function PreviewPanel({
</div> </div>
)} )}
<div className="bg-white/5 rounded-2xl p-6 border border-white/10 backdrop-blur-sm"> <div className={embedded ? "" : "bg-white/5 rounded-2xl p-6 border border-white/10 backdrop-blur-sm"}>
<h2 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">🎥 </h2> {!embedded && <h2 className="text-lg font-semibold text-white mb-4 flex items-center gap-2"></h2>}
<div className="aspect-video bg-black/50 rounded-xl overflow-hidden flex items-center justify-center"> <div className="aspect-video bg-black/50 rounded-xl overflow-hidden flex items-center justify-center">
{generatedVideo ? ( {generatedVideo ? (
<video src={generatedVideo} controls preload="metadata" className="w-full h-full object-contain" /> <video src={generatedVideo} controls preload="metadata" className="w-full h-full object-contain" />
@@ -71,4 +73,6 @@ export function PreviewPanel({
</div> </div>
</> </>
); );
return content;
} }

View File

@@ -92,7 +92,7 @@ export function RefAudioPanel({
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<div className="flex justify-between items-center mb-2"> <div className="flex justify-between items-center mb-2">
<span className="text-sm text-gray-300">📁 </span> <span className="text-sm text-gray-300">📁 <span className="text-xs text-gray-500 font-normal">(3-10)</span></span>
<div className="flex gap-2"> <div className="flex gap-2">
<input <input
type="file" type="file"
@@ -187,7 +187,7 @@ export function RefAudioPanel({
<div className="text-white text-xs truncate pr-1 flex-1" title={audio.name}> <div className="text-white text-xs truncate pr-1 flex-1" title={audio.name}>
{audio.name} {audio.name}
</div> </div>
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity"> <div className="flex gap-1 opacity-40 group-hover:opacity-100 transition-opacity">
<button <button
onClick={(e) => onTogglePlayPreview(audio, e)} onClick={(e) => onTogglePlayPreview(audio, e)}
className="text-gray-400 hover:text-purple-400 text-xs" className="text-gray-400 hover:text-purple-400 text-xs"
@@ -287,9 +287,6 @@ export function RefAudioPanel({
)} )}
</div> </div>
<p className="text-xs text-gray-500 mt-2 border-t border-white/10 pt-3">
3-10
</p>
</div> </div>
); );
} }

View File

@@ -86,7 +86,7 @@ export function ScriptEditor({
<div className="relative z-10 bg-white/5 rounded-2xl p-4 sm:p-6 border border-white/10 backdrop-blur-sm"> <div className="relative z-10 bg-white/5 rounded-2xl p-4 sm:p-6 border border-white/10 backdrop-blur-sm">
<div className="mb-4 space-y-3"> <div className="mb-4 space-y-3">
<h2 className="text-base sm:text-lg font-semibold text-white flex items-center gap-2"> <h2 className="text-base sm:text-lg font-semibold text-white flex items-center gap-2">
</h2> </h2>
<div className="flex gap-2 flex-wrap justify-end items-center"> <div className="flex gap-2 flex-wrap justify-end items-center">
{/* 历史文案 */} {/* 历史文案 */}
@@ -123,7 +123,7 @@ export function ScriptEditor({
e.stopPropagation(); e.stopPropagation();
onDeleteScript(script.id); onDeleteScript(script.id);
}} }}
className="opacity-0 group-hover:opacity-100 p-1 text-gray-500 hover:text-red-400 transition-all shrink-0" className="opacity-40 group-hover:opacity-100 p-1 text-gray-500 hover:text-red-400 transition-all shrink-0"
> >
<Trash2 className="h-3 w-3" /> <Trash2 className="h-3 w-3" />
</button> </button>

View File

@@ -26,9 +26,13 @@ export default function ScriptExtractionModal({
selectedFile, selectedFile,
activeTab, activeTab,
inputUrl, inputUrl,
customPrompt,
showCustomPrompt,
setDoRewrite, setDoRewrite,
setActiveTab, setActiveTab,
setInputUrl, setInputUrl,
setCustomPrompt,
setShowCustomPrompt,
handleDrag, handleDrag,
handleDrop, handleDrop,
handleFileChange, handleFileChange,
@@ -187,18 +191,43 @@ export default function ScriptExtractionModal({
)} )}
{/* Options */} {/* Options */}
<div className="flex items-center gap-3 bg-white/5 rounded-xl p-4 border border-white/10"> <div className="bg-white/5 rounded-xl border border-white/10 overflow-hidden">
<label className="flex items-center gap-2 cursor-pointer"> <div className="flex items-center justify-between p-4">
<input <label className="flex items-center gap-2 cursor-pointer">
type="checkbox" <input
checked={doRewrite} type="checkbox"
onChange={(e) => setDoRewrite(e.target.checked)} checked={doRewrite}
className="w-4 h-4 rounded bg-white/10 border-white/20 text-purple-500 focus:ring-purple-500" onChange={(e) => setDoRewrite(e.target.checked)}
/> className="w-4 h-4 rounded bg-white/10 border-white/20 text-purple-500 focus:ring-purple-500"
<span className="text-sm text-gray-300"> />
AI <span className="text-sm text-gray-300">
</span> AI
</label> </span>
</label>
{doRewrite && (
<button
type="button"
onClick={() => setShowCustomPrompt(!showCustomPrompt)}
className="text-xs text-purple-400 hover:text-purple-300 transition-colors flex items-center gap-1"
>
{showCustomPrompt ? "▲" : "▼"}
</button>
)}
</div>
{doRewrite && showCustomPrompt && (
<div className="px-4 pb-4 space-y-2">
<textarea
value={customPrompt}
onChange={(e) => setCustomPrompt(e.target.value)}
placeholder="输入自定义改写提示词..."
rows={3}
className="w-full bg-black/20 border border-white/10 rounded-lg px-3 py-2 text-sm text-white placeholder-gray-500 focus:outline-none focus:border-purple-500 transition-colors resize-none"
/>
<p className="text-xs text-gray-500">
使
</p>
</div>
)}
</div> </div>
{/* Error */} {/* Error */}
@@ -261,7 +290,7 @@ export default function ScriptExtractionModal({
<div className="space-y-2"> <div className="space-y-2">
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<h4 className="font-semibold text-purple-300 flex items-center gap-2"> <h4 className="font-semibold text-purple-300 flex items-center gap-2">
AI 稿{" "} AI {" "}
<span className="text-xs font-normal text-purple-400/70"> <span className="text-xs font-normal text-purple-400/70">
() ()
</span> </span>
@@ -281,7 +310,7 @@ export default function ScriptExtractionModal({
📋 📋
</button> </button>
</div> </div>
<div className="bg-purple-900/10 border border-purple-500/20 rounded-xl p-4 max-h-60 overflow-y-auto custom-scrollbar"> <div className="bg-purple-900/10 border border-purple-500/20 rounded-xl p-4 max-h-60 overflow-y-auto hide-scrollbar">
<p className="text-gray-200 text-sm leading-relaxed whitespace-pre-wrap"> <p className="text-gray-200 text-sm leading-relaxed whitespace-pre-wrap">
{rewrittenScript} {rewrittenScript}
</p> </p>
@@ -309,7 +338,7 @@ export default function ScriptExtractionModal({
</button> </button>
</div> </div>
<div className="bg-white/5 border border-white/10 rounded-xl p-4 max-h-40 overflow-y-auto custom-scrollbar"> <div className="bg-white/5 border border-white/10 rounded-xl p-4 max-h-40 overflow-y-auto hide-scrollbar">
<p className="text-gray-400 text-sm leading-relaxed whitespace-pre-wrap"> <p className="text-gray-400 text-sm leading-relaxed whitespace-pre-wrap">
{script} {script}
</p> </p>

View File

@@ -1,9 +1,9 @@
import { useEffect, useRef, useCallback, useState } from "react"; import { useEffect, useRef, useCallback, useState, useMemo } from "react";
import WaveSurfer from "wavesurfer.js"; import WaveSurfer from "wavesurfer.js";
import { ChevronDown } from "lucide-react"; import { ChevronDown, GripVertical } from "lucide-react";
import type { TimelineSegment } from "@/features/home/model/useTimelineEditor"; import type { TimelineSegment } from "@/features/home/model/useTimelineEditor";
import type { Material } from "@/shared/types/material"; import type { Material } from "@/shared/types/material";
interface TimelineEditorProps { interface TimelineEditorProps {
audioDuration: number; audioDuration: number;
audioUrl: string; audioUrl: string;
@@ -13,14 +13,15 @@ interface TimelineEditorProps {
onOutputAspectRatioChange: (ratio: "9:16" | "16:9") => void; onOutputAspectRatioChange: (ratio: "9:16" | "16:9") => void;
onReorderSegment: (fromIdx: number, toIdx: number) => void; onReorderSegment: (fromIdx: number, toIdx: number) => void;
onClickSegment: (segment: TimelineSegment) => void; onClickSegment: (segment: TimelineSegment) => void;
embedded?: boolean;
} }
function formatTime(sec: number): string { function formatTime(sec: number): string {
const m = Math.floor(sec / 60); const m = Math.floor(sec / 60);
const s = sec % 60; const s = sec % 60;
return `${String(m).padStart(2, "0")}:${s.toFixed(1).padStart(4, "0")}`; return `${String(m).padStart(2, "0")}:${s.toFixed(1).padStart(4, "0")}`;
} }
export function TimelineEditor({ export function TimelineEditor({
audioDuration, audioDuration,
audioUrl, audioUrl,
@@ -30,12 +31,13 @@ export function TimelineEditor({
onOutputAspectRatioChange, onOutputAspectRatioChange,
onReorderSegment, onReorderSegment,
onClickSegment, onClickSegment,
embedded = false,
}: TimelineEditorProps) { }: TimelineEditorProps) {
const waveRef = useRef<HTMLDivElement>(null); const waveRef = useRef<HTMLDivElement>(null);
const wsRef = useRef<WaveSurfer | null>(null); const wsRef = useRef<WaveSurfer | null>(null);
const [waveReady, setWaveReady] = useState(false); const [waveReady, setWaveReady] = useState(false);
const [isPlaying, setIsPlaying] = useState(false); const [isPlaying, setIsPlaying] = useState(false);
// Refs for high-frequency DOM updates (avoid 60fps re-renders) // Refs for high-frequency DOM updates (avoid 60fps re-renders)
const playheadRef = useRef<HTMLDivElement>(null); const playheadRef = useRef<HTMLDivElement>(null);
const timeRef = useRef<HTMLSpanElement>(null); const timeRef = useRef<HTMLSpanElement>(null);
@@ -44,7 +46,7 @@ export function TimelineEditor({
useEffect(() => { useEffect(() => {
audioDurationRef.current = audioDuration; audioDurationRef.current = audioDuration;
}, [audioDuration]); }, [audioDuration]);
// Drag-to-reorder state // Drag-to-reorder state
const [dragFromIdx, setDragFromIdx] = useState<number | null>(null); const [dragFromIdx, setDragFromIdx] = useState<number | null>(null);
const [dragOverIdx, setDragOverIdx] = useState<number | null>(null); const [dragOverIdx, setDragOverIdx] = useState<number | null>(null);
@@ -68,57 +70,57 @@ export function TimelineEditor({
if (ratioOpen) document.addEventListener("mousedown", handler); if (ratioOpen) document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler); return () => document.removeEventListener("mousedown", handler);
}, [ratioOpen]); }, [ratioOpen]);
// Create / recreate wavesurfer when audioUrl changes // Create / recreate wavesurfer when audioUrl changes
useEffect(() => { useEffect(() => {
if (!waveRef.current || !audioUrl) return; if (!waveRef.current || !audioUrl) return;
const playheadEl = playheadRef.current; const playheadEl = playheadRef.current;
const timeEl = timeRef.current; const timeEl = timeRef.current;
// Destroy previous instance // Destroy previous instance
if (wsRef.current) { if (wsRef.current) {
wsRef.current.destroy(); wsRef.current.destroy();
wsRef.current = null; wsRef.current = null;
} }
const ws = WaveSurfer.create({ const ws = WaveSurfer.create({
container: waveRef.current, container: waveRef.current,
height: 56, height: 56,
waveColor: "#6d28d9", waveColor: "#6d28d9",
progressColor: "#a855f7", progressColor: "#a855f7",
barWidth: 2, barWidth: 2,
barGap: 1, barGap: 1,
barRadius: 2, barRadius: 2,
cursorWidth: 1, cursorWidth: 1,
cursorColor: "#e879f9", cursorColor: "#e879f9",
interact: true, interact: true,
normalize: true, normalize: true,
}); });
// Click waveform → seek + auto-play // Click waveform → seek + auto-play
ws.on("interaction", () => ws.play()); ws.on("interaction", () => ws.play());
ws.on("play", () => setIsPlaying(true)); ws.on("play", () => setIsPlaying(true));
ws.on("pause", () => setIsPlaying(false)); ws.on("pause", () => setIsPlaying(false));
ws.on("finish", () => { ws.on("finish", () => {
setIsPlaying(false); setIsPlaying(false);
if (playheadRef.current) playheadRef.current.style.display = "none"; if (playheadRef.current) playheadRef.current.style.display = "none";
}); });
// High-frequency: update playhead + time via refs (no React re-render) // High-frequency: update playhead + time via refs (no React re-render)
ws.on("timeupdate", (time: number) => { ws.on("timeupdate", (time: number) => {
const dur = audioDurationRef.current; const dur = audioDurationRef.current;
if (playheadRef.current && dur > 0) { if (playheadRef.current && dur > 0) {
playheadRef.current.style.left = `${(time / dur) * 100}%`; playheadRef.current.style.left = `${(time / dur) * 100}%`;
playheadRef.current.style.display = "block"; playheadRef.current.style.display = "block";
} }
if (timeRef.current) { if (timeRef.current) {
timeRef.current.textContent = formatTime(time); timeRef.current.textContent = formatTime(time);
} }
}); });
ws.load(audioUrl); ws.load(audioUrl);
wsRef.current = ws; wsRef.current = ws;
return () => { return () => {
ws.destroy(); ws.destroy();
wsRef.current = null; wsRef.current = null;
@@ -127,60 +129,64 @@ export function TimelineEditor({
if (timeEl) timeEl.textContent = formatTime(0); if (timeEl) timeEl.textContent = formatTime(0);
}; };
}, [audioUrl, waveReady]); }, [audioUrl, waveReady]);
// Callback ref to detect when waveRef div mounts // Callback ref to detect when waveRef div mounts
const waveCallbackRef = useCallback((node: HTMLDivElement | null) => { const waveCallbackRef = useCallback((node: HTMLDivElement | null) => {
(waveRef as React.MutableRefObject<HTMLDivElement | null>).current = node; (waveRef as React.MutableRefObject<HTMLDivElement | null>).current = node;
setWaveReady(!!node); setWaveReady(!!node);
}, []); }, []);
const handlePlayPause = useCallback(() => { const handlePlayPause = useCallback(() => {
wsRef.current?.playPause(); wsRef.current?.playPause();
}, []); }, []);
// Drag-to-reorder handlers // Drag-to-reorder handlers
const handleDragStart = useCallback((idx: number, e: React.DragEvent) => { const handleDragStart = useCallback((idx: number, e: React.DragEvent) => {
setDragFromIdx(idx); setDragFromIdx(idx);
e.dataTransfer.effectAllowed = "move"; e.dataTransfer.effectAllowed = "move";
e.dataTransfer.setData("text/plain", String(idx)); e.dataTransfer.setData("text/plain", String(idx));
}, []); }, []);
const handleDragOver = useCallback((idx: number, e: React.DragEvent) => { const handleDragOver = useCallback((idx: number, e: React.DragEvent) => {
e.preventDefault(); e.preventDefault();
e.dataTransfer.dropEffect = "move"; e.dataTransfer.dropEffect = "move";
setDragOverIdx(idx); setDragOverIdx(idx);
}, []); }, []);
const handleDragLeave = useCallback(() => { const handleDragLeave = useCallback(() => {
setDragOverIdx(null); setDragOverIdx(null);
}, []); }, []);
const handleDrop = useCallback((toIdx: number, e: React.DragEvent) => { const handleDrop = useCallback((toIdx: number, e: React.DragEvent) => {
e.preventDefault(); e.preventDefault();
const fromIdx = parseInt(e.dataTransfer.getData("text/plain"), 10); const fromIdx = parseInt(e.dataTransfer.getData("text/plain"), 10);
if (!isNaN(fromIdx) && fromIdx !== toIdx) { if (!isNaN(fromIdx) && fromIdx !== toIdx) {
onReorderSegment(fromIdx, toIdx); onReorderSegment(fromIdx, toIdx);
} }
setDragFromIdx(null); setDragFromIdx(null);
setDragOverIdx(null); setDragOverIdx(null);
}, [onReorderSegment]); }, [onReorderSegment]);
const handleDragEnd = useCallback(() => { const handleDragEnd = useCallback(() => {
setDragFromIdx(null); setDragFromIdx(null);
setDragOverIdx(null); setDragOverIdx(null);
}, []); }, []);
// Filter visible vs overflow segments // Filter visible vs overflow segments
const visibleSegments = segments.filter((s) => s.start < audioDuration); const visibleSegments = useMemo(() => segments.filter((s) => s.start < audioDuration), [segments, audioDuration]);
const overflowSegments = segments.filter((s) => s.start >= audioDuration); const overflowSegments = useMemo(() => segments.filter((s) => s.start >= audioDuration), [segments, audioDuration]);
const hasSegments = visibleSegments.length > 0; const hasSegments = visibleSegments.length > 0;
return ( const content = (
<div className="bg-white/5 rounded-2xl p-4 sm:p-6 border border-white/10 backdrop-blur-sm"> <>
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
<h2 className="text-base sm:text-lg font-semibold text-white flex items-center gap-2"> {!embedded ? (
🎞 <h2 className="text-base sm:text-lg font-semibold text-white flex items-center gap-2">
</h2>
</h2>
) : (
<h3 className="text-sm font-medium text-gray-400"></h3>
)}
<div className="flex items-center gap-2 text-xs text-gray-400"> <div className="flex items-center gap-2 text-xs text-gray-400">
<div ref={ratioRef} className="relative"> <div ref={ratioRef} className="relative">
<button <button
@@ -231,28 +237,28 @@ export function TimelineEditor({
)} )}
</div> </div>
</div> </div>
{/* Waveform — always rendered so ref stays mounted */} {/* Waveform — always rendered so ref stays mounted */}
<div className="relative mb-1"> <div className="relative mb-1">
<div ref={waveCallbackRef} className="rounded-lg overflow-hidden bg-black/20 cursor-pointer" style={{ minHeight: 56 }} /> <div ref={waveCallbackRef} className="rounded-lg overflow-hidden bg-black/20 cursor-pointer" style={{ minHeight: 56 }} />
</div> </div>
{/* Segment blocks or empty placeholder */} {/* Segment blocks or empty placeholder */}
{hasSegments ? ( {hasSegments ? (
<> <>
<div className="relative h-14 flex select-none"> <div className="relative h-14 flex select-none">
{/* Playhead — syncs with audio playback */} {/* Playhead — syncs with audio playback */}
<div <div
ref={playheadRef} ref={playheadRef}
className="absolute top-0 h-full w-0.5 bg-fuchsia-400 z-10 pointer-events-none" className="absolute top-0 h-full w-0.5 bg-fuchsia-400 z-10 pointer-events-none"
style={{ display: "none", left: "0%" }} style={{ display: "none", left: "0%" }}
/> />
{visibleSegments.map((seg, i) => { {visibleSegments.map((seg, i) => {
const left = (seg.start / audioDuration) * 100; const left = (seg.start / audioDuration) * 100;
const width = ((seg.end - seg.start) / audioDuration) * 100; const width = ((seg.end - seg.start) / audioDuration) * 100;
const segDur = seg.end - seg.start; const segDur = seg.end - seg.start;
const isDragTarget = dragOverIdx === i && dragFromIdx !== i; const isDragTarget = dragOverIdx === i && dragFromIdx !== i;
// Compute loop portion for the last visible segment // Compute loop portion for the last visible segment
const isLastVisible = i === visibleSegments.length - 1; const isLastVisible = i === visibleSegments.length - 1;
let loopPercent = 0; let loopPercent = 0;
@@ -266,84 +272,93 @@ export function TimelineEditor({
loopPercent = ((segDur - effDur) / segDur) * 100; loopPercent = ((segDur - effDur) / segDur) * 100;
} }
} }
return ( return (
<div key={seg.id} className="absolute top-0 h-full" style={{ left: `${left}%`, width: `${width}%` }}> <div key={seg.id} className="absolute top-0 h-full" style={{ left: `${left}%`, width: `${width}%` }}>
<button <button
draggable draggable
onDragStart={(e) => handleDragStart(i, e)} onDragStart={(e) => handleDragStart(i, e)}
onDragOver={(e) => handleDragOver(i, e)} onDragOver={(e) => handleDragOver(i, e)}
onDragLeave={handleDragLeave} onDragLeave={handleDragLeave}
onDrop={(e) => handleDrop(i, e)} onDrop={(e) => handleDrop(i, e)}
onDragEnd={handleDragEnd} onDragEnd={handleDragEnd}
onClick={() => onClickSegment(seg)} onClick={() => onClickSegment(seg)}
className={`relative w-full h-full rounded-lg flex flex-col items-center justify-center overflow-hidden cursor-grab active:cursor-grabbing transition-all border ${ className={`relative w-full h-full rounded-lg flex flex-col items-center justify-center overflow-hidden cursor-grab active:cursor-grabbing transition-all border ${
isDragTarget isDragTarget
? "ring-2 ring-purple-400 border-purple-400 scale-[1.02]" ? "ring-2 ring-purple-400 border-purple-400 scale-[1.02]"
: dragFromIdx === i : dragFromIdx === i
? "opacity-50 border-white/10" ? "opacity-50 border-white/10"
: "hover:opacity-90 border-white/10" : "hover:opacity-90 border-white/10"
}`} }`}
style={{ backgroundColor: seg.color + "33", borderColor: isDragTarget ? undefined : seg.color + "66" }} style={{ backgroundColor: seg.color + "33", borderColor: isDragTarget ? undefined : seg.color + "66" }}
title={`拖拽可调换顺序 · 点击设置截取范围\n${seg.materialName}\n${segDur.toFixed(1)}s${loopPercent > 0 ? ` (含循环 ${(segDur * loopPercent / 100).toFixed(1)}s)` : ""}`} title={`拖拽可调换顺序 · 点击设置截取范围\n${seg.materialName}\n${segDur.toFixed(1)}s${loopPercent > 0 ? ` (含循环 ${(segDur * loopPercent / 100).toFixed(1)}s)` : ""}`}
> >
<span className="text-[11px] text-white/90 truncate max-w-full px-1 leading-tight z-[1]"> <GripVertical className="absolute top-0.5 left-0.5 h-3 w-3 text-white/30 z-[1]" />
{seg.materialName} <span className="text-[11px] text-white/90 truncate max-w-full px-1 leading-tight z-[1]">
</span> {seg.materialName}
<span className="text-[10px] text-white/60 leading-tight z-[1]"> </span>
{segDur.toFixed(1)}s <span className="text-[10px] text-white/60 leading-tight z-[1]">
</span> {segDur.toFixed(1)}s
{seg.sourceStart > 0 && ( </span>
<span className="text-[9px] text-amber-400/80 leading-tight z-[1]"> {seg.sourceStart > 0 && (
{seg.sourceStart.toFixed(1)}s <span className="text-[9px] text-amber-400/80 leading-tight z-[1]">
</span> {seg.sourceStart.toFixed(1)}s
)} </span>
{/* Loop fill stripe overlay */} )}
{loopPercent > 0 && ( {/* Loop fill stripe overlay */}
<div {loopPercent > 0 && (
className="absolute top-0 right-0 h-full pointer-events-none flex items-center justify-center" <div
style={{ className="absolute top-0 right-0 h-full pointer-events-none flex items-center justify-center"
width: `${loopPercent}%`, style={{
background: `repeating-linear-gradient(-45deg, transparent, transparent 3px, rgba(255,255,255,0.07) 3px, rgba(255,255,255,0.07) 6px)`, width: `${loopPercent}%`,
borderLeft: "1px dashed rgba(255,255,255,0.25)", background: `repeating-linear-gradient(-45deg, transparent, transparent 3px, rgba(255,255,255,0.07) 3px, rgba(255,255,255,0.07) 6px)`,
}} borderLeft: "1px dashed rgba(255,255,255,0.25)",
> }}
<span className="text-[9px] text-white/30"></span> >
</div> <span className="text-[9px] text-white/30"></span>
)} </div>
</button> )}
</div> </button>
); </div>
})} );
</div> })}
</div>
{/* Overflow segments — shown as gray chips */}
{overflowSegments.length > 0 && ( {/* Overflow segments — shown as gray chips */}
<div className="flex flex-wrap items-center gap-1.5 mt-1.5"> {overflowSegments.length > 0 && (
<span className="text-[10px] text-gray-500">使:</span> <div className="flex flex-wrap items-center gap-1.5 mt-1.5">
{overflowSegments.map((seg) => ( <span className="text-[10px] text-gray-500">使:</span>
<span {overflowSegments.map((seg) => (
key={seg.id} <span
className="text-[10px] text-gray-500 bg-white/5 border border-white/10 rounded px-1.5 py-0.5" key={seg.id}
> className="text-[10px] text-gray-500 bg-white/5 border border-white/10 rounded px-1.5 py-0.5"
{seg.materialName} >
</span> {seg.materialName}
))} </span>
</div> ))}
)} </div>
)}
<p className="text-[10px] text-gray-500 mt-1.5">
· · <p className="text-[10px] text-gray-500 mt-1.5">
</p> · ·
</> </p>
) : ( </>
<> ) : (
<div className="h-14 bg-white/5 rounded-lg" /> <>
<p className="text-[10px] text-gray-500 mt-1.5"> <div className="h-14 bg-white/5 rounded-lg" />
<p className="text-[10px] text-gray-500 mt-1.5">
</p>
</> </p>
)} </>
</div> )}
); </>
} );
if (embedded) return content;
return (
<div className="bg-white/5 rounded-2xl p-4 sm:p-6 border border-white/10 backdrop-blur-sm">
{content}
</div>
);
}

View File

@@ -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,
@@ -94,24 +114,42 @@ export function TitleSubtitlePanel({
<div className="bg-white/5 rounded-2xl p-4 sm:p-6 border border-white/10 backdrop-blur-sm"> <div className="bg-white/5 rounded-2xl p-4 sm:p-6 border border-white/10 backdrop-blur-sm">
<div className="flex items-center justify-between mb-4 gap-2"> <div className="flex items-center justify-between mb-4 gap-2">
<h2 className="text-base sm:text-lg font-semibold text-white flex items-center gap-2"> <h2 className="text-base sm:text-lg font-semibold text-white flex items-center gap-2">
🎬
</h2> </h2>
<button <div className="flex items-center gap-1.5">
onClick={onTogglePreview} <div className="relative shrink-0">
className="px-2 py-1 text-xs bg-white/10 hover:bg-white/20 rounded text-gray-300 flex items-center gap-1" <select
> value={titleDisplayMode}
<Eye className="h-3.5 w-3.5" /> onChange={(e) => onTitleDisplayModeChange(e.target.value as "short" | "persistent")}
{showStylePreview ? "收起预览" : "预览样式"} className="appearance-none rounded-lg border border-white/15 bg-black/35 px-2.5 py-1.5 pr-7 text-xs text-gray-200 outline-none transition-colors hover:border-white/25 focus:border-purple-500"
</button> aria-label="标题显示方式"
>
<option value="short"></option>
<option value="persistent"></option>
</select>
<ChevronDown className="pointer-events-none absolute right-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-gray-400" />
</div>
<button
onClick={onTogglePreview}
className="px-2 py-1 text-xs bg-white/10 hover:bg-white/20 rounded text-gray-300 flex items-center gap-1"
>
<Eye className="h-3.5 w-3.5" />
{showStylePreview ? "收起预览" : "预览样式"}
</button>
</div>
</div> </div>
{showStylePreview && ( {showStylePreview && (
<FloatingStylePreview <FloatingStylePreview
onClose={onTogglePreview} onClose={onTogglePreview}
videoTitle={videoTitle} videoTitle={videoTitle}
videoSecondaryTitle={videoSecondaryTitle}
titleStyles={titleStyles} titleStyles={titleStyles}
selectedTitleStyleId={selectedTitleStyleId} selectedTitleStyleId={selectedTitleStyleId}
titleFontSize={titleFontSize} titleFontSize={titleFontSize}
selectedSecondaryTitleStyleId={selectedSecondaryTitleStyleId}
secondaryTitleFontSize={secondaryTitleFontSize}
secondaryTitleTopMargin={secondaryTitleTopMargin}
subtitleStyles={subtitleStyles} subtitleStyles={subtitleStyles}
selectedSubtitleStyleId={selectedSubtitleStyleId} selectedSubtitleStyleId={selectedSubtitleStyleId}
subtitleFontSize={subtitleFontSize} subtitleFontSize={subtitleFontSize}
@@ -127,20 +165,9 @@ export function TitleSubtitlePanel({
)} )}
<div className="mb-4"> <div className="mb-4">
<div className="mb-2 flex items-center justify-between gap-2"> <div className="flex items-center justify-between mb-2">
<label className="text-sm text-gray-300">15</label> <label className="text-sm text-gray-300"></label>
<div className="relative shrink-0"> <span className={`text-xs ${videoTitle.length > 15 ? "text-red-400" : "text-gray-500"}`}>{videoTitle.length}/15</span>
<select
value={titleDisplayMode}
onChange={(e) => onTitleDisplayModeChange(e.target.value as "short" | "persistent")}
className="appearance-none rounded-lg border border-white/15 bg-black/35 px-2.5 py-1.5 pr-7 text-xs text-gray-200 outline-none transition-colors hover:border-white/25 focus:border-purple-500"
aria-label="标题显示方式"
>
<option value="short"></option>
<option value="persistent"></option>
</select>
<ChevronDown className="pointer-events-none absolute right-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-gray-400" />
</div>
</div> </div>
<input <input
type="text" type="text"
@@ -153,96 +180,102 @@ export function TitleSubtitlePanel({
/> />
</div> </div>
<div className="mb-4">
<div className="flex items-center justify-between mb-2">
<label className="text-sm text-gray-300"></label>
<span className={`text-xs ${videoSecondaryTitle.length > 20 ? "text-red-400" : "text-gray-500"}`}>{videoSecondaryTitle.length}/20</span>
</div>
<input
type="text"
value={videoSecondaryTitle}
onChange={(e) => onSecondaryTitleChange(e.target.value)}
onCompositionStart={onSecondaryTitleCompositionStart}
onCompositionEnd={(e) => onSecondaryTitleCompositionEnd?.(e.currentTarget.value)}
placeholder="输入副标题,显示在主标题下方"
className="w-full px-3 sm:px-4 py-2 text-sm sm:text-base bg-black/30 border border-white/10 rounded-xl text-white placeholder-gray-500 focus:outline-none focus:border-purple-500 transition-colors"
/>
</div>
{titleStyles.length > 0 && ( {titleStyles.length > 0 && (
<div className="mb-4"> <div className="mb-4 space-y-3">
<label className="text-sm text-gray-300 mb-2 block"></label> <div className="flex items-center gap-3">
<div className="grid grid-cols-2 gap-2"> <label className="text-sm text-gray-300 shrink-0 w-20"></label>
{titleStyles.map((style) => ( <div className="relative w-1/3 min-w-[100px]">
<button <select
key={style.id} value={selectedTitleStyleId}
onClick={() => onSelectTitleStyle(style.id)} onChange={(e) => onSelectTitleStyle(e.target.value)}
className={`p-2 rounded-lg border transition-all text-left ${selectedTitleStyleId === style.id className="w-full appearance-none rounded-lg border border-white/15 bg-black/35 px-3 py-2 pr-8 text-sm text-gray-200 outline-none transition-colors hover:border-white/25 focus:border-purple-500"
? "border-purple-500 bg-purple-500/20"
: "border-white/10 bg-white/5 hover:border-white/30"
}`}
> >
<div className="text-white text-sm truncate">{style.label}</div> {titleStyles.map((style) => (
<div className="text-xs text-gray-400 truncate"> <option key={style.id} value={style.id}>{style.label}</option>
{style.font_family || style.font_file || ""} ))}
</div> </select>
</button> <ChevronDown className="pointer-events-none absolute right-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-gray-400" />
))} </div>
</div> </div>
<div className="mt-3"> <div className="flex items-center gap-3">
<label className="text-xs text-gray-400 mb-2 block">: {titleFontSize}px</label> <label className="text-xs text-gray-400 shrink-0 w-20"> {titleFontSize}</label>
<input <input type="range" min="60" max="150" step="1" value={titleFontSize} onChange={(e) => onTitleFontSizeChange(parseInt(e.target.value, 10))} className="flex-1 accent-purple-500" />
type="range"
min="60"
max="150"
step="1"
value={titleFontSize}
onChange={(e) => onTitleFontSizeChange(parseInt(e.target.value, 10))}
className="w-full accent-purple-500"
/>
</div> </div>
<div className="mt-3"> <div className="flex items-center gap-3">
<label className="text-xs text-gray-400 mb-2 block">: {titleTopMargin}px</label> <label className="text-xs text-gray-400 shrink-0 w-20"> {titleTopMargin}</label>
<input <input type="range" min="0" max="300" step="1" value={titleTopMargin} onChange={(e) => onTitleTopMarginChange(parseInt(e.target.value, 10))} className="flex-1 accent-purple-500" />
type="range" </div>
min="0" </div>
max="300" )}
step="1"
value={titleTopMargin} {titleStyles.length > 0 && (
onChange={(e) => onTitleTopMarginChange(parseInt(e.target.value, 10))} <div className="mb-4 space-y-3">
className="w-full accent-purple-500" <div className="flex items-center gap-3">
/> <label className="text-sm text-gray-300 shrink-0 w-20"></label>
<div className="relative w-1/3 min-w-[100px]">
<select
value={selectedSecondaryTitleStyleId}
onChange={(e) => onSelectSecondaryTitleStyle(e.target.value)}
className="w-full appearance-none rounded-lg border border-white/15 bg-black/35 px-3 py-2 pr-8 text-sm text-gray-200 outline-none transition-colors hover:border-white/25 focus:border-purple-500"
>
{titleStyles.map((style) => (
<option key={style.id} value={style.id}>{style.label}</option>
))}
</select>
<ChevronDown className="pointer-events-none absolute right-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-gray-400" />
</div>
</div>
<div className="flex items-center gap-3">
<label className="text-xs text-gray-400 shrink-0 w-20"> {secondaryTitleFontSize}</label>
<input type="range" min="30" max="100" step="1" value={secondaryTitleFontSize} onChange={(e) => onSecondaryTitleFontSizeChange(parseInt(e.target.value, 10))} className="flex-1 accent-purple-500" />
</div>
<div className="flex items-center gap-3">
<label className="text-xs text-gray-400 shrink-0 w-20"> {secondaryTitleTopMargin}</label>
<input type="range" min="0" max="100" step="1" value={secondaryTitleTopMargin} onChange={(e) => onSecondaryTitleTopMarginChange(parseInt(e.target.value, 10))} className="flex-1 accent-purple-500" />
</div> </div>
</div> </div>
)} )}
{subtitleStyles.length > 0 && ( {subtitleStyles.length > 0 && (
<div className="mt-4"> <div className="mt-4 space-y-3">
<label className="text-sm text-gray-300 mb-2 block"></label> <div className="flex items-center gap-3">
<div className="grid grid-cols-2 gap-2"> <label className="text-sm text-gray-300 shrink-0 w-20"></label>
{subtitleStyles.map((style) => ( <div className="relative w-1/3 min-w-[100px]">
<button <select
key={style.id} value={selectedSubtitleStyleId}
onClick={() => onSelectSubtitleStyle(style.id)} onChange={(e) => onSelectSubtitleStyle(e.target.value)}
className={`p-2 rounded-lg border transition-all text-left ${selectedSubtitleStyleId === style.id className="w-full appearance-none rounded-lg border border-white/15 bg-black/35 px-3 py-2 pr-8 text-sm text-gray-200 outline-none transition-colors hover:border-white/25 focus:border-purple-500"
? "border-purple-500 bg-purple-500/20"
: "border-white/10 bg-white/5 hover:border-white/30"
}`}
> >
<div className="text-white text-sm truncate">{style.label}</div> {subtitleStyles.map((style) => (
<div className="text-xs text-gray-400 truncate"> <option key={style.id} value={style.id}>{style.label}</option>
{style.font_family || style.font_file || ""} ))}
</div> </select>
</button> <ChevronDown className="pointer-events-none absolute right-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-gray-400" />
))} </div>
</div> </div>
<div className="mt-3"> <div className="flex items-center gap-3">
<label className="text-xs text-gray-400 mb-2 block">: {subtitleFontSize}px</label> <label className="text-xs text-gray-400 shrink-0 w-20"> {subtitleFontSize}</label>
<input <input type="range" min="40" max="90" step="1" value={subtitleFontSize} onChange={(e) => onSubtitleFontSizeChange(parseInt(e.target.value, 10))} className="flex-1 accent-purple-500" />
type="range"
min="40"
max="90"
step="1"
value={subtitleFontSize}
onChange={(e) => onSubtitleFontSizeChange(parseInt(e.target.value, 10))}
className="w-full accent-purple-500"
/>
</div> </div>
<div className="mt-3"> <div className="flex items-center gap-3">
<label className="text-xs text-gray-400 mb-2 block">: {subtitleBottomMargin}px</label> <label className="text-xs text-gray-400 shrink-0 w-20"> {subtitleBottomMargin}</label>
<input <input type="range" min="0" max="300" step="1" value={subtitleBottomMargin} onChange={(e) => onSubtitleBottomMarginChange(parseInt(e.target.value, 10))} className="flex-1 accent-purple-500" />
type="range"
min="0"
max="300"
step="1"
value={subtitleBottomMargin}
onChange={(e) => onSubtitleBottomMarginChange(parseInt(e.target.value, 10))}
className="w-full accent-purple-500"
/>
</div> </div>
</div> </div>
)} )}

View File

@@ -13,6 +13,7 @@ interface VoiceSelectorProps {
voice: string; voice: string;
onSelectVoice: (id: string) => void; onSelectVoice: (id: string) => void;
voiceCloneSlot: ReactNode; voiceCloneSlot: ReactNode;
embedded?: boolean;
} }
export function VoiceSelector({ export function VoiceSelector({
@@ -22,32 +23,29 @@ export function VoiceSelector({
voice, voice,
onSelectVoice, onSelectVoice,
voiceCloneSlot, voiceCloneSlot,
embedded = false,
}: VoiceSelectorProps) { }: VoiceSelectorProps) {
return ( const content = (
<div className="bg-white/5 rounded-2xl p-6 border border-white/10 backdrop-blur-sm"> <>
<h2 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
🎙
</h2>
<div className="flex gap-2 mb-4"> <div className="flex gap-2 mb-4">
<button <button
onClick={() => onSelectTtsMode("edgetts")} onClick={() => onSelectTtsMode("edgetts")}
className={`flex-1 py-2 px-4 rounded-lg font-medium transition-all flex items-center justify-center gap-2 ${ttsMode === "edgetts" className={`flex-1 py-2 px-2 sm:px-4 rounded-lg text-sm sm:text-base font-medium transition-all flex items-center justify-center gap-1.5 sm:gap-2 ${ttsMode === "edgetts"
? "bg-purple-600 text-white" ? "bg-purple-600 text-white"
: "bg-white/10 text-gray-300 hover:bg-white/20" : "bg-white/10 text-gray-300 hover:bg-white/20"
}`} }`}
> >
<Volume2 className="h-4 w-4" /> <Volume2 className="h-4 w-4 shrink-0" />
</button> </button>
<button <button
onClick={() => onSelectTtsMode("voiceclone")} onClick={() => onSelectTtsMode("voiceclone")}
className={`flex-1 py-2 px-4 rounded-lg font-medium transition-all flex items-center justify-center gap-2 ${ttsMode === "voiceclone" className={`flex-1 py-2 px-2 sm:px-4 rounded-lg text-sm sm:text-base font-medium transition-all flex items-center justify-center gap-1.5 sm:gap-2 ${ttsMode === "voiceclone"
? "bg-purple-600 text-white" ? "bg-purple-600 text-white"
: "bg-white/10 text-gray-300 hover:bg-white/20" : "bg-white/10 text-gray-300 hover:bg-white/20"
}`} }`}
> >
<Mic className="h-4 w-4" /> <Mic className="h-4 w-4 shrink-0" />
</button> </button>
</div> </div>
@@ -70,6 +68,17 @@ export function VoiceSelector({
)} )}
{ttsMode === "voiceclone" && voiceCloneSlot} {ttsMode === "voiceclone" && voiceCloneSlot}
</>
);
if (embedded) return content;
return (
<div className="bg-white/5 rounded-2xl p-6 border border-white/10 backdrop-blur-sm">
<h2 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
🎙
</h2>
{content}
</div> </div>
); );
} }

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback } from "react"; import { useState, useEffect, useCallback, useRef } from "react";
import api from "@/shared/api/axios"; import api from "@/shared/api/axios";
import { ApiResponse, unwrap } from "@/shared/api/types"; import { ApiResponse, unwrap } from "@/shared/api/types";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -7,6 +7,7 @@ export type ExtractionStep = "config" | "processing" | "result";
export type InputTab = "file" | "url"; export type InputTab = "file" | "url";
const VALID_FILE_TYPES = [".mp4", ".mov", ".avi", ".mp3", ".wav", ".m4a"]; const VALID_FILE_TYPES = [".mp4", ".mov", ".avi", ".mp3", ".wav", ".m4a"];
const CUSTOM_PROMPT_KEY = "vigent_rewriteCustomPrompt";
interface UseScriptExtractionOptions { interface UseScriptExtractionOptions {
isOpen: boolean; isOpen: boolean;
@@ -23,8 +24,19 @@ export const useScriptExtraction = ({ isOpen }: UseScriptExtractionOptions) => {
const [selectedFile, setSelectedFile] = useState<File | null>(null); const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [activeTab, setActiveTab] = useState<InputTab>("url"); const [activeTab, setActiveTab] = useState<InputTab>("url");
const [inputUrl, setInputUrl] = useState(""); const [inputUrl, setInputUrl] = useState("");
const [customPrompt, setCustomPrompt] = useState(() => typeof window !== "undefined" ? localStorage.getItem(CUSTOM_PROMPT_KEY) || "" : "");
const [showCustomPrompt, setShowCustomPrompt] = useState(false);
// Reset state when modal opens // Debounced save customPrompt to localStorage
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
useEffect(() => {
debounceRef.current = setTimeout(() => {
localStorage.setItem(CUSTOM_PROMPT_KEY, customPrompt);
}, 300);
return () => clearTimeout(debounceRef.current);
}, [customPrompt]);
// Reset state when modal opens (customPrompt is persistent, not reset)
useEffect(() => { useEffect(() => {
if (isOpen) { if (isOpen) {
setStep("config"); setStep("config");
@@ -101,6 +113,9 @@ export const useScriptExtraction = ({ isOpen }: UseScriptExtractionOptions) => {
formData.append("url", inputUrl.trim()); formData.append("url", inputUrl.trim());
} }
formData.append("rewrite", doRewrite ? "true" : "false"); formData.append("rewrite", doRewrite ? "true" : "false");
if (doRewrite && customPrompt.trim()) {
formData.append("custom_prompt", customPrompt.trim());
}
const { data: res } = await api.post< const { data: res } = await api.post<
ApiResponse<{ original_script: string; rewritten_script?: string }> ApiResponse<{ original_script: string; rewritten_script?: string }>
@@ -126,7 +141,7 @@ export const useScriptExtraction = ({ isOpen }: UseScriptExtractionOptions) => {
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
}, [activeTab, selectedFile, inputUrl, doRewrite]); }, [activeTab, selectedFile, inputUrl, doRewrite, customPrompt]);
const copyToClipboard = useCallback((text: string) => { const copyToClipboard = useCallback((text: string) => {
if (navigator.clipboard && window.isSecureContext) { if (navigator.clipboard && window.isSecureContext) {
@@ -193,10 +208,14 @@ export const useScriptExtraction = ({ isOpen }: UseScriptExtractionOptions) => {
selectedFile, selectedFile,
activeTab, activeTab,
inputUrl, inputUrl,
customPrompt,
showCustomPrompt,
// Setters // Setters
setDoRewrite, setDoRewrite,
setActiveTab, setActiveTab,
setInputUrl, setInputUrl,
setCustomPrompt,
setShowCustomPrompt,
// Handlers // Handlers
handleDrag, handleDrag,
handleDrop, handleDrop,

View File

@@ -135,7 +135,7 @@ export function PublishPage() {
<div className="space-y-6"> <div className="space-y-6">
<div className="bg-white/5 rounded-2xl p-6 border border-white/10 backdrop-blur-sm"> <div className="bg-white/5 rounded-2xl p-6 border border-white/10 backdrop-blur-sm">
<h2 className="text-lg font-semibold text-white mb-4 flex items-center gap-2"> <h2 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
👤
</h2> </h2>
{isAccountsLoading ? ( {isAccountsLoading ? (
@@ -157,62 +157,60 @@ export function PublishPage() {
))} ))}
</div> </div>
) : ( ) : (
<div className="space-y-3"> <div className="space-y-2 sm:space-y-3">
{accounts.map((account) => ( {accounts.map((account) => (
<div <div
key={account.platform} key={account.platform}
className="flex items-center justify-between p-4 bg-black/30 rounded-xl" className="flex items-center gap-3 px-3 py-2.5 sm:px-4 sm:py-3.5 bg-black/30 rounded-xl"
> >
<div className="flex items-center gap-3"> {platformIcons[account.platform] ? (
{platformIcons[account.platform] ? ( <Image
<Image src={platformIcons[account.platform].src}
src={platformIcons[account.platform].src} alt={platformIcons[account.platform].alt}
alt={platformIcons[account.platform].alt} width={28}
width={28} height={28}
height={28} className="h-6 w-6 sm:h-7 sm:w-7 shrink-0"
className="h-7 w-7" />
/> ) : (
) : ( <span className="text-xl sm:text-2xl">🌐</span>
<span className="text-2xl">🌐</span> )}
)} <div className="min-w-0 flex-1">
<div> <div className="text-sm sm:text-base text-white font-medium leading-tight">
<div className="text-white font-medium"> {account.name}
{account.name} </div>
</div> <div
<div className={`text-xs sm:text-sm leading-tight ${account.logged_in
className={`text-sm ${account.logged_in ? "text-green-400"
? "text-green-400" : "text-gray-500"
: "text-gray-500" }`}
}`} >
> {account.logged_in ? "✓ 已登录" : "未登录"}
{account.logged_in ? "✓ 已登录" : "未登录"}
</div>
</div> </div>
</div> </div>
<div className="flex gap-2"> <div className="flex items-center gap-1.5 sm:gap-2 shrink-0">
{account.logged_in ? ( {account.logged_in ? (
<> <>
<button <button
onClick={() => handleLogin(account.platform)} onClick={() => handleLogin(account.platform)}
className="px-3 py-1 bg-white/10 hover:bg-white/20 text-white text-sm rounded-lg transition-colors flex items-center gap-1" className="px-2 py-1 sm:px-3 sm:py-1.5 bg-white/10 hover:bg-white/20 text-white text-xs sm:text-sm rounded-md sm:rounded-lg transition-colors flex items-center gap-1"
> >
<RotateCcw className="h-3.5 w-3.5" /> <RotateCcw className="h-3 w-3 sm:h-3.5 sm:w-3.5" />
</button> </button>
<button <button
onClick={() => handleLogout(account.platform)} onClick={() => handleLogout(account.platform)}
className="px-3 py-1 bg-red-500/80 hover:bg-red-600 text-white text-sm rounded-lg transition-colors flex items-center gap-1" className="px-2 py-1 sm:px-3 sm:py-1.5 bg-red-500/80 hover:bg-red-600 text-white text-xs sm:text-sm rounded-md sm:rounded-lg transition-colors flex items-center gap-1"
> >
<LogOut className="h-3.5 w-3.5" /> <LogOut className="h-3 w-3 sm:h-3.5 sm:w-3.5" />
</button> </button>
</> </>
) : ( ) : (
<button <button
onClick={() => handleLogin(account.platform)} onClick={() => handleLogin(account.platform)}
className="px-3 py-1 bg-purple-500/80 hover:bg-purple-600 text-white text-sm rounded-lg transition-colors flex items-center gap-1" className="px-2 py-1 sm:px-3 sm:py-1.5 bg-purple-500/80 hover:bg-purple-600 text-white text-xs sm:text-sm rounded-md sm:rounded-lg transition-colors flex items-center gap-1"
> >
<QrCode className="h-3.5 w-3.5" /> <QrCode className="h-3 w-3 sm:h-3.5 sm:w-3.5" />
</button> </button>
)} )}
@@ -228,7 +226,7 @@ export function PublishPage() {
<div className="space-y-6"> <div className="space-y-6">
{/* 选择视频 */} {/* 选择视频 */}
<div className="bg-white/5 rounded-2xl p-6 border border-white/10 backdrop-blur-sm"> <div className="bg-white/5 rounded-2xl p-6 border border-white/10 backdrop-blur-sm">
<h2 className="text-lg font-semibold text-white mb-4">📹 </h2> <h2 className="text-lg font-semibold text-white mb-4"></h2>
<div className="flex items-center gap-3 mb-4"> <div className="flex items-center gap-3 mb-4">
<Search className="text-gray-400 w-4 h-4" /> <Search className="text-gray-400 w-4 h-4" />
@@ -303,7 +301,7 @@ export function PublishPage() {
{/* 填写信息 */} {/* 填写信息 */}
<div className="bg-white/5 rounded-2xl p-6 border border-white/10 backdrop-blur-sm"> <div className="bg-white/5 rounded-2xl p-6 border border-white/10 backdrop-blur-sm">
<h2 className="text-lg font-semibold text-white mb-4"> </h2> <h2 className="text-lg font-semibold text-white mb-4"></h2>
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
@@ -337,7 +335,7 @@ export function PublishPage() {
{/* 选择平台 */} {/* 选择平台 */}
<div className="bg-white/5 rounded-2xl p-6 border border-white/10 backdrop-blur-sm"> <div className="bg-white/5 rounded-2xl p-6 border border-white/10 backdrop-blur-sm">
<h2 className="text-lg font-semibold text-white mb-4">📱 </h2> <h2 className="text-lg font-semibold text-white mb-4"></h2>
<div className="grid grid-cols-3 gap-3"> <div className="grid grid-cols-3 gap-3">
{accounts {accounts

View File

@@ -11,6 +11,7 @@ interface AuthContextType {
user: User | null; user: User | null;
isLoading: boolean; isLoading: boolean;
isAuthenticated: boolean; isAuthenticated: boolean;
setUser: (user: User | null) => void;
} }
const AuthContext = createContext<AuthContextType>({ const AuthContext = createContext<AuthContextType>({
@@ -18,6 +19,7 @@ const AuthContext = createContext<AuthContextType>({
user: null, user: null,
isLoading: true, isLoading: true,
isAuthenticated: false, isAuthenticated: false,
setUser: () => {},
}); });
export function AuthProvider({ children }: { children: ReactNode }) { export function AuthProvider({ children }: { children: ReactNode }) {
@@ -63,7 +65,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
userId: user?.id || null, userId: user?.id || null,
user, user,
isLoading, isLoading,
isAuthenticated: !!user isAuthenticated: !!user,
setUser,
}}> }}>
{children} {children}
</AuthContext.Provider> </AuthContext.Provider>

View File

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

@@ -16,21 +16,23 @@ export const RemotionRoot: React.FC = () => {
fps={25} fps={25}
width={1080} width={1080}
height={1920} height={1920}
calculateMetadata={async ({ props }) => ({ calculateMetadata={async ({ props }: { props: Record<string, unknown> }) => ({
width: props.width || 1080, width: (props.width as number) || 1080,
height: props.height || 1920, height: (props.height as number) || 1920,
})} })}
defaultProps={{ defaultProps={{
videoSrc: '', videoSrc: '',
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,
}} } satisfies VideoProps}
/> />
</> </>
); );

View File

@@ -10,13 +10,16 @@ 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;
[key: string]: unknown;
} }
/** /**
@@ -28,11 +31,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 +50,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

@@ -38,16 +38,14 @@ const getFontFormat = (fontFile?: string) => {
return 'truetype'; return 'truetype';
}; };
const buildTextShadow = (color: string, size: number) => { /**
return [ * 构建描边样式(使用 CSS 原生描边,避免 textShadow 重影)
`-${size}px -${size}px 0 ${color}`, */
`${size}px -${size}px 0 ${color}`, const buildStrokeStyle = (color: string, size: number): React.CSSProperties => ({
`-${size}px ${size}px 0 ${color}`, WebkitTextStroke: `${size}px ${color}`,
`${size}px ${size}px 0 ${color}`, paintOrder: 'stroke fill',
`0 0 ${size * 4}px rgba(0,0,0,0.9)`, textShadow: `0 2px 4px rgba(0,0,0,0.4)`,
`0 4px 8px rgba(0,0,0,0.6)` });
].join(',');
};
export const Subtitles: React.FC<SubtitlesProps> = ({ captions, style }) => { export const Subtitles: React.FC<SubtitlesProps> = ({ captions, style }) => {
const frame = useCurrentFrame(); const frame = useCurrentFrame();
@@ -133,7 +131,7 @@ export const Subtitles: React.FC<SubtitlesProps> = ({ captions, style }) => {
key={`${word.word}-${index}`} key={`${word.word}-${index}`}
style={{ style={{
color: isHighlighted ? highlightColor : normalColor, color: isHighlighted ? highlightColor : normalColor,
textShadow: buildTextShadow(strokeColor, strokeSize), ...buildStrokeStyle(strokeColor, strokeSize),
transition: 'color 0.05s ease', transition: 'color 0.05s ease',
}} }}
> >

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;
} }
/** /**
@@ -42,23 +44,24 @@ const getFontFormat = (fontFile?: string) => {
return 'truetype'; return 'truetype';
}; };
const buildTextShadow = (color: string, size: number) => { /**
return [ * 构建描边样式(使用 CSS 原生描边,避免 textShadow 重影)
`-${size}px -${size}px 0 ${color}`, * 返回需要合并到 style 对象上的属性
`${size}px -${size}px 0 ${color}`, */
`-${size}px ${size}px 0 ${color}`, const buildStrokeStyle = (color: string, size: number): React.CSSProperties => ({
`${size}px ${size}px 0 ${color}`, WebkitTextStroke: `${size}px ${color}`,
`0 0 ${size * 4}px rgba(0,0,0,0.9)`, paintOrder: 'stroke fill',
`0 4px 8px rgba(0,0,0,0.6)` textShadow: `0 2px 4px rgba(0,0,0,0.3)`,
].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 +133,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 +175,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)`,
@@ -157,7 +193,7 @@ export const Title: React.FC<TitleProps> = ({
fontSize: `${fontSize}px`, fontSize: `${fontSize}px`,
fontWeight, fontWeight,
fontFamily: fontFamilyCss, fontFamily: fontFamilyCss,
textShadow: buildTextShadow(strokeColor, strokeSize), ...buildStrokeStyle(strokeColor, strokeSize),
margin: 0, margin: 0,
width: '100%', width: '100%',
boxSizing: 'border-box', boxSizing: 'border-box',
@@ -171,6 +207,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,
...buildStrokeStyle(stStrokeColor, stStrokeSize),
margin: 0,
marginTop: `${stMarginTop}px`,
width: '100%',
boxSizing: 'border-box',
padding: '0 5%',
lineHeight: 1.3,
whiteSpace: 'normal',
wordBreak: 'break-word',
overflowWrap: 'anywhere',
letterSpacing: `${stLetterSpacing}px`,
}}
>
{secondaryTitle}
</h2>
)}
</AbsoluteFill> </AbsoluteFill>
); );
}; };

View File

@@ -21,7 +21,6 @@ export const VideoLayer: React.FC<VideoLayerProps> = ({
<AbsoluteFill> <AbsoluteFill>
<OffthreadVideo <OffthreadVideo
src={videoUrl} src={videoUrl}
loop
style={{ style={{
width: '100%', width: '100%',
height: '100%', height: '100%',