From 1717635bfdf5ccdd25d8ebf0422ff36993824ff1 Mon Sep 17 00:00:00 2001 From: Kevin Wong Date: Wed, 25 Feb 2026 17:51:58 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + Docs/DevLogs/Day26.md | 239 ++++++++++ Docs/FRONTEND_DEV.md | 59 ++- Docs/FRONTEND_README.md | 28 +- Docs/SUBTITLE_DEPLOY.md | 1 + Docs/task_complete.md | 30 +- backend/app/services/lipsync_service.py | 2 +- backend/app/services/whisper_service.py | 60 ++- frontend/src/app/login/page.tsx | 3 + .../components/AccountSettingsDropdown.tsx | 5 + .../features/home/model/useHomeController.ts | 37 +- frontend/src/features/home/ui/BgmPanel.tsx | 2 +- frontend/src/features/home/ui/ClipTrimmer.tsx | 6 +- .../features/home/ui/FloatingStylePreview.tsx | 12 +- .../features/home/ui/GeneratedAudiosPanel.tsx | 185 ++++--- frontend/src/features/home/ui/HistoryList.tsx | 38 +- frontend/src/features/home/ui/HomePage.tsx | 260 ++++++---- .../src/features/home/ui/MaterialSelector.tsx | 51 +- .../src/features/home/ui/PreviewPanel.tsx | 14 +- .../src/features/home/ui/RefAudioPanel.tsx | 7 +- .../src/features/home/ui/ScriptEditor.tsx | 4 +- .../home/ui/ScriptExtractionModal.tsx | 4 +- .../src/features/home/ui/TimelineEditor.tsx | 451 +++++++++--------- .../features/home/ui/TitleSubtitlePanel.tsx | 229 ++++----- .../src/features/home/ui/VoiceSelector.tsx | 29 +- .../src/features/publish/ui/PublishPage.tsx | 72 ++- frontend/src/shared/contexts/AuthContext.tsx | 5 +- 27 files changed, 1172 insertions(+), 662 deletions(-) create mode 100644 Docs/DevLogs/Day26.md diff --git a/.gitignore b/.gitignore index ae49093..e6a05c9 100644 --- a/.gitignore +++ b/.gitignore @@ -40,6 +40,7 @@ backend/uploads/ backend/cookies/ backend/user_data/ backend/debug_screenshots/ +backend/keys/ *_cookies.json # ============ 模型权重 ============ diff --git a/Docs/DevLogs/Day26.md b/Docs/DevLogs/Day26.md new file mode 100644 index 0000000..8bd3245 --- /dev/null +++ b/Docs/DevLogs/Day26.md @@ -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` +- **分隔线**: `
` + +### 5. 配音列表布局优化 + +GeneratedAudiosPanel embedded 模式下采用两行布局: +- **第 1 行**:语速下拉 + 生成配音按钮(右对齐,`flex justify-end`) +- **第 2 行**:`

配音列表

` + 刷新按钮(两端对齐) +- 非 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` ref(1 秒内禁止所有列表自动滚动)替代单次 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、MaterialSelector(2处)、ScriptExtractionModal(2处) +- 滚动功能不受影响,仅视觉上不显示滚动条 + +### 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,自渲染子标题+操作按钮,useMemo,disabled 守卫,操作按钮可见度,标题溢出修复 | +| `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 长视频推理不再超时回退 +- 字幕时间戳与语音节奏同步,长视频不漂移 diff --git a/Docs/FRONTEND_DEV.md b/Docs/FRONTEND_DEV.md index de7f04e..12fec09 100644 --- a/Docs/FRONTEND_DEV.md +++ b/Docs/FRONTEND_DEV.md @@ -151,6 +151,33 @@ body { | `sm:` | ≥ 640px | 平板/桌面 | | `lg:` | ≥ 1024px | 大屏桌面 | +### embedded 组件模式 + +合并板块时,子组件通过 `embedded?: boolean` prop 控制是否渲染外层卡片容器和主标题。 + +```tsx +// embedded=false(独立使用):渲染完整卡片 +
+

标题

+ {content} +
+ +// embedded=true(嵌入父卡片):只渲染内容 +{content} +``` + +- 子标题使用 `

` +- 分隔线使用 `
` +- 移动端标题行避免 `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); +}, []); + +// 列表滚动 effect(BGM/素材/视频等) +useEffect(() => { + if (!selectedId || !scrollEffectsEnabled.current) return; + target?.scrollIntoView({ block: "nearest", behavior: "smooth" }); +}, [selectedId, list]); +``` ### 路由预取 diff --git a/Docs/FRONTEND_README.md b/Docs/FRONTEND_README.md index 0eacd5c..0be5f7c 100644 --- a/Docs/FRONTEND_README.md +++ b/Docs/FRONTEND_README.md @@ -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(不遮挡控件) +- **输入辅助**: 标题/副标题输入框实时字数计数器,超限变红 diff --git a/Docs/SUBTITLE_DEPLOY.md b/Docs/SUBTITLE_DEPLOY.md index fe8aa54..425081f 100644 --- a/Docs/SUBTITLE_DEPLOY.md +++ b/Docs/SUBTITLE_DEPLOY.md @@ -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 节奏映射,修复长视频字幕漂移 | diff --git a/Docs/task_complete.md b/Docs/task_complete.md index 78e21f2..4bb64e3 100644 --- a/Docs/task_complete.md +++ b/Docs/task_complete.md @@ -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` ref,1 秒内禁止自动滚动)+ 延迟兜底 `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 超时从 1200s(20 分钟)改为 3600s(1 小时),修复 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 持久化。 diff --git a/backend/app/services/lipsync_service.py b/backend/app/services/lipsync_service.py index 189de32..28af4d9 100644 --- a/backend/app/services/lipsync_service.py +++ b/backend/app/services/lipsync_service.py @@ -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) diff --git a/backend/app/services/whisper_service.py b/backend/app/services/whisper_service.py index 37dad83..1a3f80b 100644 --- a/backend/app/services/whisper_service.py +++ b/backend/app/services/whisper_service.py @@ -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} diff --git a/frontend/src/app/login/page.tsx b/frontend/src/app/login/page.tsx index 0b1319b..08706b2 100644 --- a/frontend/src/app/login/page.tsx +++ b/frontend/src/app/login/page.tsx @@ -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 || '登录失败'); diff --git a/frontend/src/components/AccountSettingsDropdown.tsx b/frontend/src/components/AccountSettingsDropdown.tsx index 3e74e5c..2574f65 100644 --- a/frontend/src/components/AccountSettingsDropdown.tsx +++ b/frontend/src/components/AccountSettingsDropdown.tsx @@ -106,6 +106,10 @@ export default function AccountSettingsDropdown() { {/* 下拉菜单 */} {isOpen && (
+ {/* 账户名称 */} +
+
{user?.phone ? `${user.phone.slice(0, 3)}****${user.phone.slice(-4)}` : '未知账户'}
+
{/* 有效期显示 */}
账户有效期
@@ -188,6 +192,7 @@ export default function AccountSettingsDropdown() { onClick={() => { setShowPasswordModal(false); setError(''); + setSuccess(''); setOldPassword(''); setNewPassword(''); setConfirmPassword(''); diff --git a/frontend/src/features/home/model/useHomeController.ts b/frontend/src/features/home/model/useHomeController.ts index e7419e2..03fd455 100644 --- a/frontend/src/features/home/model/useHomeController.ts +++ b/frontend/src/features/home/model/useHomeController.ts @@ -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); } diff --git a/frontend/src/features/home/ui/BgmPanel.tsx b/frontend/src/features/home/ui/BgmPanel.tsx index fbd4694..938d69e 100644 --- a/frontend/src/features/home/ui/BgmPanel.tsx +++ b/frontend/src/features/home/ui/BgmPanel.tsx @@ -43,7 +43,7 @@ export function BgmPanel({ return (
-

🎵 背景音乐

+

五、背景音乐

- {speedOpen && ( -
- {speedOptions.map((opt) => ( - - ))} -
- )} -
- )} - - + const content = ( + <> + {embedded ? ( + <> + {/* Row 1: 语速 + 生成配音 (right-aligned) */} +
+ {ttsMode === "voiceclone" && ( +
+ + {speedOpen && ( +
+ {speedOptions.map((opt) => ( + + ))} +
+ )} +
+ )} + +
+ {/* Row 2: 配音列表 + 刷新 */} +
+

配音列表

+ +
+ + ) : ( +
+

+ + 配音列表 +

+
+ {ttsMode === "voiceclone" && ( +
+ + {speedOpen && ( +
+ {speedOptions.map((opt) => ( + + ))} +
+ )} +
+ )} + + +
-
+ )} {/* 缺少参考音频提示 */} {missingRefAudio && ( @@ -250,7 +312,7 @@ export function GeneratedAudiosPanel({
{audio.name}
{audio.duration_sec.toFixed(1)}s
-
+
)} + + ); + if (embedded) return content; + + return ( +
+ {content}
); } diff --git a/frontend/src/features/home/ui/HistoryList.tsx b/frontend/src/features/home/ui/HistoryList.tsx index 5281b29..31714e6 100644 --- a/frontend/src/features/home/ui/HistoryList.tsx +++ b/frontend/src/features/home/ui/HistoryList.tsx @@ -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 ( -
-
-

📂 历史作品

- -
+ const content = ( + <> + {!embedded && ( +
+

历史作品

+ +
+ )} {generatedVideos.length === 0 ? (

暂无生成的作品

@@ -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="删除视频" > @@ -75,6 +79,14 @@ export function HistoryList({ ))}
)} + + ); + + if (embedded) return content; + + return ( +
+ {content}
); } diff --git a/frontend/src/features/home/ui/HomePage.tsx b/frontend/src/features/home/ui/HomePage.tsx index 17cc989..ff0b77f 100644 --- a/frontend/src/features/home/ui/HomePage.tsx +++ b/frontend/src/features/home/ui/HomePage.tsx @@ -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() {
{/* 左侧: 输入区域 */}
- {/* 1. 文案输入 */} + {/* 一、文案提取与编辑 */} - {/* 2. 标题和字幕设置 */} + {/* 二、标题与字幕 */} setShowStylePreview((prev) => !prev)} @@ -268,65 +277,77 @@ export function HomePage() { previewBaseHeight={outputAspectRatio === "16:9" ? 1080 : 1920} /> - {/* 3. 配音方式选择 */} - 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} - /> - )} - /> + {/* 三、配音 */} +
+

+ 三、配音 +

+

配音方式

+ 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} + /> + )} + /> +
+ fetchGeneratedAudios()} + onSelectAudio={selectAudio} + onDeleteAudio={deleteAudio} + onRenameAudio={renameAudio} + hasText={!!text.trim()} + missingRefAudio={ttsMode === "voiceclone" && !selectedRefAudio} + speed={speed} + onSpeedChange={setSpeed} + ttsMode={ttsMode} + /> +
- {/* 4. 配音列表 */} - fetchGeneratedAudios()} - onSelectAudio={selectAudio} - onDeleteAudio={deleteAudio} - onRenameAudio={renameAudio} - hasText={!!text.trim()} - missingRefAudio={ttsMode === "voiceclone" && !selectedRefAudio} - speed={speed} - onSpeedChange={setSpeed} - ttsMode={ttsMode} - /> - - {/* 5. 视频素材 */} - +

+ 四、素材编辑 +

+ setUploadError(null)} registerMaterialRef={registerMaterialRef} /> - - {/* 5.5 时间轴编辑器 — 未选配音/素材时模糊遮挡 */} -
- {(!selectedAudio || selectedMaterials.length === 0) && ( -
-

- {!selectedAudio ? "请先生成并选中配音" : "请先选择素材"} -

-
- )} - { - setClipTrimmerSegmentId(seg.id); - setClipTrimmerOpen(true); - }} - /> +
+
+ {(!selectedAudio || selectedMaterials.length === 0) && ( +
+

+ {!selectedAudio ? "请先生成并选中配音" : "请先选择素材"} +

+
+ )} + { + setClipTrimmerSegmentId(seg.id); + setClipTrimmerOpen(true); + }} + /> +
- {/* 6. 背景音乐 */} + {/* 背景音乐 (不编号) */} - {/* 7. 生成按钮 */} + {/* 生成按钮 (不编号) */}
- {/* 右侧: 预览区域 */} + {/* 右侧: 作品区域 */}
- - - fetchGeneratedVideos()} - registerVideoRef={registerVideoRef} - formatDate={formatDate} - /> + {/* 生成进度(在作品卡片上方) */} + {currentTask && isGenerating && ( +
+
+
+ 正在AI生成中... + {currentTask.progress || 0}% +
+
+
+
+
+
+ )} + {/* 六、作品 */} +
+

+ 六、作品 +

+
+

作品列表

+ +
+ fetchGeneratedVideos()} + registerVideoRef={registerVideoRef} + formatDate={formatDate} + /> +
+

作品预览

+ +
diff --git a/frontend/src/features/home/ui/MaterialSelector.tsx b/frontend/src/features/home/ui/MaterialSelector.tsx index 9c5a803..6402ef4 100644 --- a/frontend/src/features/home/ui/MaterialSelector.tsx +++ b/frontend/src/features/home/ui/MaterialSelector.tsx @@ -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 ( -
+ const content = ( + <>
-

- 📹 视频素材 - - (可多选,最多4个) - -

+ {!embedded ? ( +

+ 视频素材 + + (上传自拍视频,最多可选4个) + +

+ ) : ( +

+ 视频素材 + (上传自拍视频,最多可选4个) +

+ )}
- 📤 上传中... + 上传中... {uploadProgress}%
@@ -108,7 +117,7 @@ export function MaterialSelector({ {uploadError && (
- ❌ {uploadError} + {uploadError} @@ -138,7 +147,7 @@ export function MaterialSelector({
📁

暂无视频素材

- 点击上方「📤 上传视频」按钮添加视频素材 + 点击上方「上传」按钮添加视频素材

) : ( @@ -183,7 +192,7 @@ export function MaterialSelector({
) : ( -
)} + + ); + + if (embedded) return content; + + return ( +
+ {content}
); } diff --git a/frontend/src/features/home/ui/PreviewPanel.tsx b/frontend/src/features/home/ui/PreviewPanel.tsx index 84e0ed6..b7c3c68 100644 --- a/frontend/src/features/home/ui/PreviewPanel.tsx +++ b/frontend/src/features/home/ui/PreviewPanel.tsx @@ -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 && ( -
-

⏳ 生成进度

+
+ {!embedded &&

生成进度

}
)} -
-

🎥 作品预览

+
+ {!embedded &&

作品预览

}
{generatedVideo ? (
); + + return content; } diff --git a/frontend/src/features/home/ui/RefAudioPanel.tsx b/frontend/src/features/home/ui/RefAudioPanel.tsx index 27e8d54..0087edc 100644 --- a/frontend/src/features/home/ui/RefAudioPanel.tsx +++ b/frontend/src/features/home/ui/RefAudioPanel.tsx @@ -92,7 +92,7 @@ export function RefAudioPanel({
- 📁 我的参考音频 + 📁 我的参考音频 (上传3-10秒语音样本)
{audio.name}
-
+
-

- 上传任意语音样本(3-10秒),系统将自动识别内容并克隆声音 -

); } diff --git a/frontend/src/features/home/ui/ScriptEditor.tsx b/frontend/src/features/home/ui/ScriptEditor.tsx index 4b94329..2b6a471 100644 --- a/frontend/src/features/home/ui/ScriptEditor.tsx +++ b/frontend/src/features/home/ui/ScriptEditor.tsx @@ -86,7 +86,7 @@ export function ScriptEditor({

- ✍️ 文案提取与编辑 + 一、文案提取与编辑

{/* 历史文案 */} @@ -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" > diff --git a/frontend/src/features/home/ui/ScriptExtractionModal.tsx b/frontend/src/features/home/ui/ScriptExtractionModal.tsx index 9daa417..503b00d 100644 --- a/frontend/src/features/home/ui/ScriptExtractionModal.tsx +++ b/frontend/src/features/home/ui/ScriptExtractionModal.tsx @@ -310,7 +310,7 @@ export default function ScriptExtractionModal({ 📋 复制内容
-
+

{rewrittenScript}

@@ -338,7 +338,7 @@ export default function ScriptExtractionModal({ 复制
-
+

{script}

diff --git a/frontend/src/features/home/ui/TimelineEditor.tsx b/frontend/src/features/home/ui/TimelineEditor.tsx index b2afdbd..6a8ed49 100644 --- a/frontend/src/features/home/ui/TimelineEditor.tsx +++ b/frontend/src/features/home/ui/TimelineEditor.tsx @@ -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(null); - const wsRef = useRef(null); - const [waveReady, setWaveReady] = useState(false); - const [isPlaying, setIsPlaying] = useState(false); - + const waveRef = useRef(null); + const wsRef = useRef(null); + const [waveReady, setWaveReady] = useState(false); + const [isPlaying, setIsPlaying] = useState(false); + // Refs for high-frequency DOM updates (avoid 60fps re-renders) const playheadRef = useRef(null); const timeRef = useRef(null); @@ -44,7 +46,7 @@ export function TimelineEditor({ useEffect(() => { audioDurationRef.current = audioDuration; }, [audioDuration]); - + // Drag-to-reorder state const [dragFromIdx, setDragFromIdx] = useState(null); const [dragOverIdx, setDragOverIdx] = useState(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).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 ( -
+ + // Callback ref to detect when waveRef div mounts + const waveCallbackRef = useCallback((node: HTMLDivElement | null) => { + (waveRef as React.MutableRefObject).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 = ( + <>
-

- 🎞️ 时间轴编辑 -

+ {!embedded ? ( +

+ 时间轴编辑 +

+ ) : ( +

时间轴编辑

+ )}
- - {/* Waveform — always rendered so ref stays mounted */} -
-
-
- - {/* Segment blocks or empty placeholder */} - {hasSegments ? ( - <> -
- {/* Playhead — syncs with audio playback */} -
- {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 */} +
+
+
+ + {/* Segment blocks or empty placeholder */} + {hasSegments ? ( + <> +
+ {/* Playhead — syncs with audio playback */} +
+ {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 ( -
- -
- ); - })} -
- - {/* Overflow segments — shown as gray chips */} - {overflowSegments.length > 0 && ( -
- 未使用: - {overflowSegments.map((seg) => ( - - {seg.materialName} - - ))} -
- )} - -

- 点击波形定位播放 · 拖拽色块调换顺序 · 点击色块设置截取范围 -

- - ) : ( - <> -
-

- 选中配音和素材后可编辑时间轴 -

- - )} -
- ); -} + + return ( +
+ +
+ ); + })} +
+ + {/* Overflow segments — shown as gray chips */} + {overflowSegments.length > 0 && ( +
+ 未使用: + {overflowSegments.map((seg) => ( + + {seg.materialName} + + ))} +
+ )} + +

+ 点击波形定位播放 · 拖拽色块调换顺序 · 点击色块设置截取范围 +

+ + ) : ( + <> +
+

+ 选中配音和素材后可编辑时间轴 +

+ + )} + + ); + + if (embedded) return content; + + return ( +
+ {content} +
+ ); +} diff --git a/frontend/src/features/home/ui/TitleSubtitlePanel.tsx b/frontend/src/features/home/ui/TitleSubtitlePanel.tsx index 7264470..a6c61b5 100644 --- a/frontend/src/features/home/ui/TitleSubtitlePanel.tsx +++ b/frontend/src/features/home/ui/TitleSubtitlePanel.tsx @@ -114,15 +114,29 @@ export function TitleSubtitlePanel({

- 🎬 标题与字幕 + 二、标题与字幕

- +
+
+ + +
+ +
{showStylePreview && ( @@ -151,20 +165,9 @@ export function TitleSubtitlePanel({ )}
-
- -
- - -
+
+ + 15 ? "text-red-400" : "text-gray-500"}`}>{videoTitle.length}/15
- +
+ + 20 ? "text-red-400" : "text-gray-500"}`}>{videoSecondaryTitle.length}/20 +
{titleStyles.length > 0 && ( -
- -
- {titleStyles.map((style) => ( -
@@ -70,6 +68,17 @@ export function VoiceSelector({ )} {ttsMode === "voiceclone" && voiceCloneSlot} + + ); + + if (embedded) return content; + + return ( +
+

+ 🎙️ 配音方式 +

+ {content}
); } diff --git a/frontend/src/features/publish/ui/PublishPage.tsx b/frontend/src/features/publish/ui/PublishPage.tsx index b5e5230..ec436b4 100644 --- a/frontend/src/features/publish/ui/PublishPage.tsx +++ b/frontend/src/features/publish/ui/PublishPage.tsx @@ -135,7 +135,7 @@ export function PublishPage() {

- 👤 平台账号 + 七、平台账号

{isAccountsLoading ? ( @@ -157,62 +157,60 @@ export function PublishPage() { ))}
) : ( -
+
{accounts.map((account) => (
-
- {platformIcons[account.platform] ? ( - {platformIcons[account.platform].alt} - ) : ( - 🌐 - )} -
-
- {account.name} -
-
- {account.logged_in ? "✓ 已登录" : "未登录"} -
+ {platformIcons[account.platform] ? ( + {platformIcons[account.platform].alt} + ) : ( + 🌐 + )} +
+
+ {account.name} +
+
+ {account.logged_in ? "✓ 已登录" : "未登录"}
-
+
{account.logged_in ? ( <> ) : ( )} @@ -228,7 +226,7 @@ export function PublishPage() {
{/* 选择视频 */}
-

📹 选择发布作品

+

八、选择发布作品

@@ -303,7 +301,7 @@ export function PublishPage() { {/* 填写信息 */}
-

✍️ 发布信息

+

九、发布信息

@@ -337,7 +335,7 @@ export function PublishPage() { {/* 选择平台 */}
-

📱 选择发布平台

+

十、选择发布平台

{accounts diff --git a/frontend/src/shared/contexts/AuthContext.tsx b/frontend/src/shared/contexts/AuthContext.tsx index 5fc2df6..8f40948 100644 --- a/frontend/src/shared/contexts/AuthContext.tsx +++ b/frontend/src/shared/contexts/AuthContext.tsx @@ -11,6 +11,7 @@ interface AuthContextType { user: User | null; isLoading: boolean; isAuthenticated: boolean; + setUser: (user: User | null) => void; } const AuthContext = createContext({ @@ -18,6 +19,7 @@ const AuthContext = createContext({ 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}