This commit is contained in:
Kevin Wong
2026-02-25 17:51:58 +08:00
parent 0a5a17402c
commit 1717635bfd
27 changed files with 1172 additions and 662 deletions

1
.gitignore vendored
View File

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

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

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

View File

@@ -151,6 +151,33 @@ body {
| `sm:` | ≥ 640px | 平板/桌面 |
| `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 请求规范
@@ -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. 视频生成 (`/`)
- **素材管理**: 拖拽上传人物视频,实时预览
- **素材重命名**: 支持在列表中直接重命名素材
- **文案配音**: 集成 EdgeTTS,支持多音色选择 (云溪 / 晓晓)
- **AI 标题/标签**: 一键生成视频标题与标签 (Day 14)
- **标题/字幕样式**: 样式选择 + 预览 + 字号调节 (Day 16)
- **背景音乐**: 试听 + 音量控制 + 选择持久化 (Day 16)
- **交互优化**: 选择项持久化、列表内定位、刷新回顶部 (Day 16)。
- **预览一致性**: 标题/字幕预览按素材分辨率缩放,效果更接近成片 (Day 17)。
- **一、文案提取与编辑**: 文案输入/提取/翻译/保存
- **二、标题与字幕**: 片头标题/副标题/字幕样式配置;短暂显示/常驻显示对标题和副标题同时生效
- **三、配音**: 配音方式(EdgeTTS/声音克隆)+ 配音列表(生成/试听/管理)合并为一个板块
- **四、素材编辑**: 视频素材(上传/选择/管理)+ 时间轴编辑(波形/色块/拖拽排序)合并为一个板块
- **五、背景音乐**: 试听 + 音量控制 + 选择持久化
- **六、作品**(右栏): 作品列表 + 作品预览合并为一个板块
- **进度追踪**: 实时显示视频生成进度 (10% -> 100%)。
- **作品预览**: 生成完成后直接播放下载(作品预览 + 历史作品)。
- **预览优化**: 预览视频 `metadata` 预取,首帧加载更快。
@@ -52,8 +50,8 @@ ViGent2 的前端界面,采用 Next.js 16 + TailwindCSS 构建。
- **画面比例控制**: 时间轴顶部支持 `9:16 / 16:9` 输出比例选择,设置持久化并透传后端。
### 5. 字幕与标题 [Day 13 新增]
- **片头标题**: 可选输入,限制 15 字;支持”短暂显示 / 常驻显示”默认短暂显示4 秒)。
- **片头副标题**: 可选输入,限制 20 字;显示在主标题下方,用于补充说明或悬念引导;独立样式配置(字体/字号/颜色/间距),可由 AI 同时生成;仅在视频画面中显示,不参与发布标题 (Day 25)。
- **片头标题**: 可选输入,限制 15 字;支持”短暂显示 / 常驻显示”默认短暂显示4 秒),对标题和副标题同时生效
- **片头副标题**: 可选输入,限制 20 字;显示在主标题下方,用于补充说明或悬念引导;独立样式配置(字体/字号/颜色/间距),可由 AI 同时生成;与标题共享显示模式设定;仅在视频画面中显示,不参与发布标题 (Day 25)。
- **标题同步**: 首页片头标题修改会同步到发布信息标题。
- **逐字高亮字幕**: 卡拉OK效果默认开启可关闭。
- **自动对齐**: 基于 faster-whisper 生成字级别时间戳。
@@ -67,8 +65,9 @@ ViGent2 的前端界面,采用 Next.js 16 + TailwindCSS 构建。
### 7. 账户设置 [Day 15 新增]
- **手机号登录**: 11位中国手机号验证登录。
- **账户下拉菜单**: 显示有效期 + 修改密码 + 安全退出。
- **账户下拉菜单**: 显示手机号(中间四位脱敏)+ 有效期 + 修改密码 + 安全退出。
- **修改密码**: 弹窗输入当前密码与新密码,修改后强制重新登录。
- **登录即时生效**: 登录成功后 AuthContext 立即写入用户数据,无需刷新即显示手机号。
### 8. 付费开通会员 (`/pay`)
- **支付宝电脑网站支付**: 跳转支付宝官方收银台,支持扫码/账号登录/余额等多种支付方式。
@@ -143,5 +142,8 @@ src/
## 🎨 设计规范
- **主色调**: 深紫/黑色系 (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-02-10 | 1.1.0 | 更新架构图:多素材 concat-then-infer、预生成配音选项 |
| 2026-01-30 | 1.0.1 | 字幕高亮样式与标题动画优化,视觉表现更清晰 |
| 2026-02-25 | 1.2.0 | 字幕时间戳从线性插值改为 Whisper 节奏映射,修复长视频字幕漂移 |

View File

@@ -1,8 +1,8 @@
# ViGent2 开发任务清单 (Task Log)
**项目**: ViGent2 数字人口播视频生成系统
**进度**: 100% (Day 25 - 支付宝付费开通会员)
**更新时间**: 2026-02-24
**进度**: 100% (Day 26 - 前端优化:板块合并 + 序号标题)
**更新时间**: 2026-02-25
---
@@ -10,7 +10,31 @@
> 这里记录了每一天的核心开发内容与 milestone。
### Day 25: 文案提取修复 + 自定义提示词 + 片头副标题 (Current)
### Day 26: 前端优化:板块合并 + 序号标题 + UI 精细化 (Current)
- [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 持久化。

View File

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

View File

@@ -247,19 +247,67 @@ class WhisperService:
line_segments = split_segment_to_lines(all_words, max_chars)
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:
logger.info(f"Using original_text for subtitles (len={len(original_text)}), "
f"Whisper time range: {whisper_first_start:.2f}-{whisper_last_end:.2f}s")
# 用 split_word_to_chars 拆分原文
# 收集 Whisper 逐字时间戳(保留真实语音节奏)
whisper_chars = []
for seg in all_segments:
whisper_chars.extend(seg.get("words", []))
# 用原文字符 + Whisper 节奏生成新的时间戳
orig_chars = split_word_to_chars(
original_text.strip(),
whisper_first_start,
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)
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")
return {"segments": all_segments}

View File

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

View File

@@ -106,6 +106,10 @@ export default function AccountSettingsDropdown() {
{/* 下拉菜单 */}
{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="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="text-xs text-gray-400"></div>
@@ -188,6 +192,7 @@ export default function AccountSettingsDropdown() {
onClick={() => {
setShowPasswordModal(false);
setError('');
setSuccess('');
setOldPassword('');
setNewPassword('');
setConfirmPassword('');

View File

@@ -617,8 +617,19 @@ export const useHomeController = () => {
// 移除重复的 BGM 持久化恢复逻辑 (已统一移动到 useHomePersistence 中)
// useEffect(() => { ... })
// 时间门控:页面加载后 1 秒内禁止所有列表自动滚动效果
// 防止持久化恢复 + 异步数据加载触发 scrollIntoView 导致移动端页面跳动
const scrollEffectsEnabled = useRef(false);
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 target = bgmItemRefs.current[selectedBgmId];
if (container && target) {
@@ -626,16 +637,10 @@ export const useHomeController = () => {
}
}, [selectedBgmId, bgmList]);
// 素材列表滚动:跳过首次恢复,仅用户主动操作时滚动
const materialScrollReady = useRef(false);
// 素材列表滚动
useEffect(() => {
const firstSelected = selectedMaterials[0];
if (!firstSelected) return;
if (!materialScrollReady.current) {
// 首次有选中素材时标记就绪,但不滚动(避免刷新后整页跳动)
materialScrollReady.current = true;
return;
}
if (!firstSelected || !scrollEffectsEnabled.current) return;
const target = materialItemRefs.current[firstSelected];
if (target) {
target.scrollIntoView({ block: "nearest", behavior: "smooth" });
@@ -660,14 +665,9 @@ export const useHomeController = () => {
}
}, [isRestored, bgmList, selectedBgmId, enableBgm, setSelectedBgmId]);
const videoScrollReady = useRef(false);
// 视频列表滚动
useEffect(() => {
if (!selectedVideoId) return;
if (!videoScrollReady.current) {
videoScrollReady.current = true;
return;
}
if (!selectedVideoId || !scrollEffectsEnabled.current) return;
const target = videoItemRefs.current[selectedVideoId];
if (target) {
target.scrollIntoView({ block: "nearest", behavior: "smooth" });
@@ -978,11 +978,14 @@ export const useHomeController = () => {
payload.title_font_size = Math.round(titleFontSize);
}
if (videoTitle.trim()) {
if (videoTitle.trim() || videoSecondaryTitle.trim()) {
payload.title_display_mode = titleDisplayMode;
if (titleDisplayMode === "short") {
payload.title_duration = DEFAULT_SHORT_TITLE_DURATION;
}
}
if (videoTitle.trim()) {
payload.title_top_margin = Math.round(titleTopMargin);
}

View File

@@ -43,7 +43,7 @@ export function BgmPanel({
return (
<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">
<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">
<button
onClick={onRefresh}

View File

@@ -213,7 +213,7 @@ export function ClipTrimmer({
{/* Custom range track */}
<div
ref={trackRef}
className="relative h-8 cursor-pointer select-none touch-none"
className="relative h-10 cursor-pointer select-none touch-none"
onPointerMove={handleTrackPointerMove}
onPointerUp={handleTrackPointerUp}
onPointerLeave={handleTrackPointerUp}
@@ -242,7 +242,7 @@ export function ClipTrimmer({
{/* Start thumb */}
<div
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}%` }}
title={`起点: ${formatSec(sourceStart)}`}
/>
@@ -250,7 +250,7 @@ export function ClipTrimmer({
{/* End thumb */}
<div
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}%` }}
title={`终点: ${formatSec(effectiveEnd)}`}
/>

View File

@@ -56,6 +56,7 @@ interface FloatingStylePreviewProps {
}
const DESKTOP_WIDTH = 280;
const MOBILE_WIDTH = 160;
export function FloatingStylePreview({
onClose,
@@ -80,9 +81,7 @@ export function FloatingStylePreview({
previewBaseHeight,
}: FloatingStylePreviewProps) {
const isMobile = typeof window !== "undefined" && window.innerWidth < 640;
const windowWidth = isMobile
? Math.min(window.innerWidth - 32, 360)
: DESKTOP_WIDTH;
const windowWidth = isMobile ? MOBILE_WIDTH : DESKTOP_WIDTH;
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
@@ -154,11 +153,12 @@ export function FloatingStylePreview({
<div
style={{
position: "fixed",
left: "16px",
top: "16px",
...(isMobile
? { right: "12px", bottom: "12px" }
: { left: "16px", top: "16px" }),
width: `${windowWidth}px`,
zIndex: 150,
maxHeight: "calc(100dvh - 32px)",
maxHeight: isMobile ? "calc(50dvh)" : "calc(100dvh - 32px)",
overflow: "hidden",
}}
className="rounded-xl border border-white/20 bg-gray-900/95 backdrop-blur-md shadow-2xl"

View File

@@ -23,6 +23,7 @@ interface GeneratedAudiosPanelProps {
speed: number;
onSpeedChange: (speed: number) => void;
ttsMode: string;
embedded?: boolean;
}
export function GeneratedAudiosPanel({
@@ -40,6 +41,7 @@ export function GeneratedAudiosPanel({
speed,
onSpeedChange,
ttsMode,
embedded = false,
}: GeneratedAudiosPanelProps) {
const [editingId, setEditingId] = useState<string | null>(null);
const [editName, setEditName] = useState("");
@@ -123,64 +125,124 @@ export function GeneratedAudiosPanel({
] as const;
const currentSpeedLabel = speedOptions.find((o) => o.value === speed)?.label ?? "正常";
return (
<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">
<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-2 py-1 text-xs rounded transition-all whitespace-nowrap flex items-center gap-1 ${
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-3.5 w-3.5" />
</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>
const content = (
<>
{embedded ? (
<>
{/* Row 1: 语速 + 生成配音 (right-aligned) */}
<div className="flex justify-end items-center gap-1.5 mb-3">
{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>
</div>
{/* Row 2: 配音列表 + 刷新 */}
<div className="flex justify-between items-center mb-3">
<h3 className="text-sm font-medium text-gray-400"></h3>
<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 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>
)}
{/* 缺少参考音频提示 */}
{missingRefAudio && (
@@ -250,7 +312,7 @@ export function GeneratedAudiosPanel({
<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>
<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
onClick={(e) => togglePlay(audio, e)}
className="p-1 text-gray-500 hover:text-purple-400 transition-colors"
@@ -287,7 +349,14 @@ export function GeneratedAudiosPanel({
})}
</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>
);
}

View File

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

View File

@@ -2,6 +2,7 @@
import { useEffect, useMemo } from "react";
import { useRouter } from "next/navigation";
import { RefreshCw } from "lucide-react";
import VideoPreviewModal from "@/components/VideoPreviewModal";
import ScriptExtractionModal from "./ScriptExtractionModal";
import { useHomeController } from "@/features/home/model/useHomeController";
@@ -179,7 +180,15 @@ export function HomePage() {
useEffect(() => {
if (typeof window === "undefined") return;
if ("scrollRestoration" in history) {
history.scrollRestoration = "manual";
}
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(
@@ -201,7 +210,7 @@ export function HomePage() {
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* 左侧: 输入区域 */}
<div className="space-y-6">
{/* 1. 文案输入 */}
{/* 一、文案提取与编辑 */}
<ScriptEditor
text={text}
onChangeText={setText}
@@ -218,7 +227,7 @@ export function HomePage() {
onDeleteScript={deleteSavedScript}
/>
{/* 2. 标题字幕设置 */}
{/* 二、标题字幕 */}
<TitleSubtitlePanel
showStylePreview={showStylePreview}
onTogglePreview={() => setShowStylePreview((prev) => !prev)}
@@ -268,65 +277,77 @@ export function HomePage() {
previewBaseHeight={outputAspectRatio === "16:9" ? 1080 : 1920}
/>
{/* 3. 配音方式选择 */}
<VoiceSelector
ttsMode={ttsMode}
onSelectTtsMode={setTtsMode}
voices={voices}
voice={voice}
onSelectVoice={setVoice}
voiceCloneSlot={(
<RefAudioPanel
refAudios={refAudios}
selectedRefAudio={selectedRefAudio}
onSelectRefAudio={handleSelectRefAudio}
isUploadingRef={isUploadingRef}
uploadRefError={uploadRefError}
onClearUploadRefError={() => setUploadRefError(null)}
onUploadRefAudio={uploadRefAudio}
onFetchRefAudios={fetchRefAudios}
playingAudioId={playingAudioId}
onTogglePlayPreview={togglePlayPreview}
editingAudioId={editingAudioId}
editName={editName}
onEditNameChange={setEditName}
onStartEditing={startEditing}
onSaveEditing={saveEditing}
onCancelEditing={cancelEditing}
onDeleteRefAudio={deleteRefAudio}
onRetranscribe={retranscribeRefAudio}
retranscribingId={retranscribingId}
recordedBlob={recordedBlob}
isRecording={isRecording}
recordingTime={recordingTime}
onStartRecording={startRecording}
onStopRecording={stopRecording}
onUseRecording={useRecording}
formatRecordingTime={formatRecordingTime}
/>
)}
/>
{/* 三、配音 */}
<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>
<h3 className="text-sm font-medium text-gray-400 mb-3"></h3>
<VoiceSelector
embedded
ttsMode={ttsMode}
onSelectTtsMode={setTtsMode}
voices={voices}
voice={voice}
onSelectVoice={setVoice}
voiceCloneSlot={(
<RefAudioPanel
refAudios={refAudios}
selectedRefAudio={selectedRefAudio}
onSelectRefAudio={handleSelectRefAudio}
isUploadingRef={isUploadingRef}
uploadRefError={uploadRefError}
onClearUploadRefError={() => setUploadRefError(null)}
onUploadRefAudio={uploadRefAudio}
onFetchRefAudios={fetchRefAudios}
playingAudioId={playingAudioId}
onTogglePlayPreview={togglePlayPreview}
editingAudioId={editingAudioId}
editName={editName}
onEditNameChange={setEditName}
onStartEditing={startEditing}
onSaveEditing={saveEditing}
onCancelEditing={cancelEditing}
onDeleteRefAudio={deleteRefAudio}
onRetranscribe={retranscribeRefAudio}
retranscribingId={retranscribingId}
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
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}
/>
{/* 5. 视频素材 */}
<MaterialSelector
{/* 四、素材编辑 */}
<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>
<MaterialSelector
embedded
materials={materials}
selectedMaterials={selectedMaterials}
isFetching={isFetching}
@@ -350,32 +371,33 @@ export function HomePage() {
onClearUploadError={() => setUploadError(null)}
registerMaterialRef={registerMaterialRef}
/>
{/* 5.5 时间轴编辑器 — 未选配音/素材时模糊遮挡 */}
<div className="relative">
{(!selectedAudio || selectedMaterials.length === 0) && (
<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">
{!selectedAudio ? "请先生成并选中配音" : "请先选择素材"}
</p>
</div>
)}
<TimelineEditor
audioDuration={selectedAudio?.duration_sec ?? 0}
audioUrl={selectedAudio ? (resolveMediaUrl(selectedAudio.path) || "") : ""}
segments={timelineSegments}
materials={materials}
outputAspectRatio={outputAspectRatio}
onOutputAspectRatioChange={setOutputAspectRatio}
onReorderSegment={reorderSegments}
onClickSegment={(seg) => {
setClipTrimmerSegmentId(seg.id);
setClipTrimmerOpen(true);
}}
/>
<div className="border-t border-white/10 my-4" />
<div className="relative">
{(!selectedAudio || selectedMaterials.length === 0) && (
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm rounded-xl flex items-center justify-center z-10">
<p className="text-gray-400">
{!selectedAudio ? "请先生成并选中配音" : "请先选择素材"}
</p>
</div>
)}
<TimelineEditor
embedded
audioDuration={selectedAudio?.duration_sec ?? 0}
audioUrl={selectedAudio ? (resolveMediaUrl(selectedAudio.path) || "") : ""}
segments={timelineSegments}
materials={materials}
outputAspectRatio={outputAspectRatio}
onOutputAspectRatioChange={setOutputAspectRatio}
onReorderSegment={reorderSegments}
onClickSegment={(seg) => {
setClipTrimmerSegmentId(seg.id);
setClipTrimmerOpen(true);
}}
/>
</div>
</div>
{/* 6. 背景音乐 */}
{/* 背景音乐 (不编号) */}
<BgmPanel
bgmList={bgmList}
bgmLoading={bgmLoading}
@@ -393,7 +415,7 @@ export function HomePage() {
registerBgmItemRef={registerBgmItemRef}
/>
{/* 7. 生成按钮 */}
{/* 生成按钮 (不编号) */}
<GenerateActionBar
isGenerating={isGenerating}
progress={currentTask?.progress || 0}
@@ -403,23 +425,59 @@ export function HomePage() {
/>
</div>
{/* 右侧: 预览区域 */}
{/* 右侧: 作品区域 */}
<div className="space-y-6">
<PreviewPanel
currentTask={currentTask}
isGenerating={isGenerating}
generatedVideo={generatedVideo}
/>
<HistoryList
generatedVideos={generatedVideos}
selectedVideoId={selectedVideoId}
onSelectVideo={handleSelectVideo}
onDeleteVideo={deleteVideo}
onRefresh={() => fetchGeneratedVideos()}
registerVideoRef={registerVideoRef}
formatDate={formatDate}
/>
{/* 生成进度(在作品卡片上方) */}
{currentTask && isGenerating && (
<div className="bg-white/5 rounded-2xl p-4 sm:p-6 border border-purple-500/30 backdrop-blur-sm">
<div className="space-y-3">
<div className="flex justify-between text-sm text-purple-300 mb-1">
<span>AI生成中...</span>
<span>{currentTask.progress || 0}%</span>
</div>
<div className="h-3 bg-black/30 rounded-full overflow-hidden">
<div
className="h-full bg-gradient-to-r from-purple-500 to-pink-500 transition-all duration-300"
style={{ width: `${currentTask.progress || 0}%` }}
/>
</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>
</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 type { Material } from "@/shared/types/material";
@@ -25,6 +25,7 @@ interface MaterialSelectorProps {
onDeleteMaterial: (id: string) => void;
onClearUploadError: () => void;
registerMaterialRef: (id: string, element: HTMLDivElement | null) => void;
embedded?: boolean;
}
export function MaterialSelector({
@@ -50,19 +51,27 @@ export function MaterialSelector({
onDeleteMaterial,
onClearUploadError,
registerMaterialRef,
embedded = false,
}: MaterialSelectorProps) {
const selectedSet = new Set(selectedMaterials);
const selectedSet = useMemo(() => new Set(selectedMaterials), [selectedMaterials]);
const isFull = selectedMaterials.length >= 4;
return (
<div className="bg-white/5 rounded-2xl p-4 sm:p-6 border border-white/10 backdrop-blur-sm">
const content = (
<>
<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">
📹
<span className="ml-1 text-[11px] sm:text-xs text-gray-400/90 font-normal">
(4)
</span>
</h2>
{!embedded ? (
<h2 className="text-base sm:text-lg font-semibold text-white flex items-center gap-2 min-w-0">
<span className="shrink-0"></span>
<span className="text-[11px] sm:text-xs text-gray-400/90 font-normal truncate">
(4)
</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">
<input
type="file"
@@ -94,7 +103,7 @@ export function MaterialSelector({
{isUploading && (
<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">
<span>📤 ...</span>
<span>...</span>
<span>{uploadProgress}%</span>
</div>
<div className="h-2 bg-black/30 rounded-full overflow-hidden">
@@ -108,7 +117,7 @@ export function MaterialSelector({
{uploadError && (
<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">
<X className="h-3.5 w-3.5" />
</button>
@@ -138,7 +147,7 @@ export function MaterialSelector({
<div className="text-5xl mb-4">📁</div>
<p></p>
<p className="text-sm mt-2">
📤
</p>
</div>
) : (
@@ -183,7 +192,7 @@ export function MaterialSelector({
</button>
</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
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);
}
}}
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="预览视频"
>
<Eye className="h-4 w-4" />
@@ -215,7 +224,7 @@ export function MaterialSelector({
{editingMaterialId !== m.id && (
<button
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="重命名"
>
<Pencil className="h-4 w-4" />
@@ -226,7 +235,7 @@ export function MaterialSelector({
e.stopPropagation();
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="删除素材"
>
<Trash2 className="h-4 w-4" />
@@ -237,6 +246,14 @@ export function MaterialSelector({
})}
</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

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

View File

@@ -92,7 +92,7 @@ export function RefAudioPanel({
<div className="space-y-4">
<div>
<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">
<input
type="file"
@@ -187,7 +187,7 @@ export function RefAudioPanel({
<div className="text-white text-xs truncate pr-1 flex-1" title={audio.name}>
{audio.name}
</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
onClick={(e) => onTogglePlayPreview(audio, e)}
className="text-gray-400 hover:text-purple-400 text-xs"
@@ -287,9 +287,6 @@ export function RefAudioPanel({
)}
</div>
<p className="text-xs text-gray-500 mt-2 border-t border-white/10 pt-3">
3-10
</p>
</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="mb-4 space-y-3">
<h2 className="text-base sm:text-lg font-semibold text-white flex items-center gap-2">
</h2>
<div className="flex gap-2 flex-wrap justify-end items-center">
{/* 历史文案 */}
@@ -123,7 +123,7 @@ export function ScriptEditor({
e.stopPropagation();
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" />
</button>

View File

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

@@ -114,15 +114,29 @@ export function TitleSubtitlePanel({
<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">
<h2 className="text-base sm:text-lg font-semibold text-white flex items-center gap-2">
🎬
</h2>
<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 className="flex items-center gap-1.5">
<div className="relative shrink-0">
<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>
<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>
{showStylePreview && (
@@ -151,20 +165,9 @@ export function TitleSubtitlePanel({
)}
<div className="mb-4">
<div className="mb-2 flex items-center justify-between gap-2">
<label className="text-sm text-gray-300">15</label>
<div className="relative shrink-0">
<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 className="flex items-center justify-between mb-2">
<label className="text-sm text-gray-300"></label>
<span className={`text-xs ${videoTitle.length > 15 ? "text-red-400" : "text-gray-500"}`}>{videoTitle.length}/15</span>
</div>
<input
type="text"
@@ -178,7 +181,10 @@ export function TitleSubtitlePanel({
</div>
<div className="mb-4">
<label className="text-sm text-gray-300 mb-2 block">20</label>
<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}
@@ -191,142 +197,85 @@ export function TitleSubtitlePanel({
</div>
{titleStyles.length > 0 && (
<div className="mb-4">
<label className="text-sm text-gray-300 mb-2 block"></label>
<div className="grid grid-cols-2 gap-2">
{titleStyles.map((style) => (
<button
key={style.id}
onClick={() => onSelectTitleStyle(style.id)}
className={`p-2 rounded-lg border transition-all text-left ${selectedTitleStyleId === style.id
? "border-purple-500 bg-purple-500/20"
: "border-white/10 bg-white/5 hover:border-white/30"
}`}
<div className="mb-4 space-y-3">
<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={selectedTitleStyleId}
onChange={(e) => onSelectTitleStyle(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"
>
<div className="text-white text-sm truncate">{style.label}</div>
<div className="text-xs text-gray-400 truncate">
{style.font_family || style.font_file || ""}
</div>
</button>
))}
{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="mt-3">
<label className="text-xs text-gray-400 mb-2 block">: {titleFontSize}px</label>
<input
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 className="flex items-center gap-3">
<label className="text-xs text-gray-400 shrink-0 w-20"> {titleFontSize}</label>
<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" />
</div>
<div className="mt-3">
<label className="text-xs text-gray-400 mb-2 block">: {titleTopMargin}px</label>
<input
type="range"
min="0"
max="300"
step="1"
value={titleTopMargin}
onChange={(e) => onTitleTopMarginChange(parseInt(e.target.value, 10))}
className="w-full accent-purple-500"
/>
<div className="flex items-center gap-3">
<label className="text-xs text-gray-400 shrink-0 w-20"> {titleTopMargin}</label>
<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" />
</div>
</div>
)}
{titleStyles.length > 0 && (
<div className="mb-4">
<label className="text-sm text-gray-300 mb-2 block"></label>
<div className="grid grid-cols-2 gap-2">
{titleStyles.map((style) => (
<button
key={style.id}
onClick={() => onSelectSecondaryTitleStyle(style.id)}
className={`p-2 rounded-lg border transition-all text-left ${selectedSecondaryTitleStyleId === style.id
? "border-purple-500 bg-purple-500/20"
: "border-white/10 bg-white/5 hover:border-white/30"
}`}
<div className="mb-4 space-y-3">
<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"
>
<div className="text-white text-sm truncate">{style.label}</div>
<div className="text-xs text-gray-400 truncate">
{style.font_family || style.font_file || ""}
</div>
</button>
))}
{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="mt-3">
<label className="text-xs text-gray-400 mb-2 block">: {secondaryTitleFontSize}px</label>
<input
type="range"
min="30"
max="100"
step="1"
value={secondaryTitleFontSize}
onChange={(e) => onSecondaryTitleFontSizeChange(parseInt(e.target.value, 10))}
className="w-full accent-purple-500"
/>
<div 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="mt-3">
<label className="text-xs text-gray-400 mb-2 block">: {secondaryTitleTopMargin}px</label>
<input
type="range"
min="0"
max="100"
step="1"
value={secondaryTitleTopMargin}
onChange={(e) => onSecondaryTitleTopMarginChange(parseInt(e.target.value, 10))}
className="w-full accent-purple-500"
/>
<div 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>
)}
{subtitleStyles.length > 0 && (
<div className="mt-4">
<label className="text-sm text-gray-300 mb-2 block"></label>
<div className="grid grid-cols-2 gap-2">
{subtitleStyles.map((style) => (
<button
key={style.id}
onClick={() => onSelectSubtitleStyle(style.id)}
className={`p-2 rounded-lg border transition-all text-left ${selectedSubtitleStyleId === style.id
? "border-purple-500 bg-purple-500/20"
: "border-white/10 bg-white/5 hover:border-white/30"
}`}
<div className="mt-4 space-y-3">
<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={selectedSubtitleStyleId}
onChange={(e) => onSelectSubtitleStyle(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"
>
<div className="text-white text-sm truncate">{style.label}</div>
<div className="text-xs text-gray-400 truncate">
{style.font_family || style.font_file || ""}
</div>
</button>
))}
{subtitleStyles.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="mt-3">
<label className="text-xs text-gray-400 mb-2 block">: {subtitleFontSize}px</label>
<input
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 className="flex items-center gap-3">
<label className="text-xs text-gray-400 shrink-0 w-20"> {subtitleFontSize}</label>
<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" />
</div>
<div className="mt-3">
<label className="text-xs text-gray-400 mb-2 block">: {subtitleBottomMargin}px</label>
<input
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 className="flex items-center gap-3">
<label className="text-xs text-gray-400 shrink-0 w-20"> {subtitleBottomMargin}</label>
<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" />
</div>
</div>
)}

View File

@@ -13,6 +13,7 @@ interface VoiceSelectorProps {
voice: string;
onSelectVoice: (id: string) => void;
voiceCloneSlot: ReactNode;
embedded?: boolean;
}
export function VoiceSelector({
@@ -22,32 +23,29 @@ export function VoiceSelector({
voice,
onSelectVoice,
voiceCloneSlot,
embedded = false,
}: VoiceSelectorProps) {
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>
const content = (
<>
<div className="flex gap-2 mb-4">
<button
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-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
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-white/10 text-gray-300 hover:bg-white/20"
}`}
>
<Mic className="h-4 w-4" />
<Mic className="h-4 w-4 shrink-0" />
</button>
</div>
@@ -70,6 +68,17 @@ export function VoiceSelector({
)}
{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>
);
}

View File

@@ -135,7 +135,7 @@ export function PublishPage() {
<div className="space-y-6">
<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>
{isAccountsLoading ? (
@@ -157,62 +157,60 @@ export function PublishPage() {
))}
</div>
) : (
<div className="space-y-3">
<div className="space-y-2 sm:space-y-3">
{accounts.map((account) => (
<div
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] ? (
<Image
src={platformIcons[account.platform].src}
alt={platformIcons[account.platform].alt}
width={28}
height={28}
className="h-7 w-7"
/>
) : (
<span className="text-2xl">🌐</span>
)}
<div>
<div className="text-white font-medium">
{account.name}
</div>
<div
className={`text-sm ${account.logged_in
? "text-green-400"
: "text-gray-500"
}`}
>
{account.logged_in ? "✓ 已登录" : "未登录"}
</div>
{platformIcons[account.platform] ? (
<Image
src={platformIcons[account.platform].src}
alt={platformIcons[account.platform].alt}
width={28}
height={28}
className="h-6 w-6 sm:h-7 sm:w-7 shrink-0"
/>
) : (
<span className="text-xl sm:text-2xl">🌐</span>
)}
<div className="min-w-0 flex-1">
<div className="text-sm sm:text-base text-white font-medium leading-tight">
{account.name}
</div>
<div
className={`text-xs sm:text-sm leading-tight ${account.logged_in
? "text-green-400"
: "text-gray-500"
}`}
>
{account.logged_in ? "✓ 已登录" : "未登录"}
</div>
</div>
<div className="flex gap-2">
<div className="flex items-center gap-1.5 sm:gap-2 shrink-0">
{account.logged_in ? (
<>
<button
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
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
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>
)}
@@ -228,7 +226,7 @@ export function PublishPage() {
<div className="space-y-6">
{/* 选择视频 */}
<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">
<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">
<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>
@@ -337,7 +335,7 @@ export function PublishPage() {
{/* 选择平台 */}
<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">
{accounts

View File

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