Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aaa8088c82 | ||
|
|
31469ca01d | ||
|
|
22ea3dd0db | ||
|
|
8a5912c517 | ||
|
|
74516dbcdb | ||
|
|
5357d97012 | ||
|
|
33d8e52802 |
@@ -1,5 +1,3 @@
|
||||
---
|
||||
|
||||
## 🔧 Qwen-TTS Flash Attention 优化 (10:00)
|
||||
|
||||
### 优化背景
|
||||
@@ -18,8 +16,6 @@ pip install -U flash-attn --no-build-isolation
|
||||
- **显存占用**: 显著降低,消除 OOM 风险
|
||||
- **代码变动**: 无代码变动,仅环境优化 (自动检测)
|
||||
|
||||
---
|
||||
|
||||
## 🛡️ 服务看门狗 Watchdog (10:30)
|
||||
|
||||
### 问题描述
|
||||
@@ -53,59 +49,86 @@ if service["failures"] >= service['threshold']:
|
||||
|
||||
---
|
||||
|
||||
## 🎨 UI 交互体验优化 (15:30)
|
||||
## 🎨 交互体验与视图优化 (14:20)
|
||||
|
||||
### 优化内容
|
||||
### 主页优化
|
||||
- 视频生成完成后,预览优先选中最新输出
|
||||
- 选择项持久化:素材 / 背景音乐 / 历史视频
|
||||
- 选择项持久化:素材 / 背景音乐 / 历史作品
|
||||
- 列表内滚动定位选中项,避免页面跳动
|
||||
- 刷新回顶部(首页 / 发布页)
|
||||
- 刷新回到顶部(首页)
|
||||
- 标题/字幕样式预览面板
|
||||
- 背景音乐试听即选中并自动开启,音量滑块实时影响试听
|
||||
|
||||
### 涉及文件
|
||||
- `frontend/src/app/page.tsx`
|
||||
- `frontend/src/app/publish/page.tsx`
|
||||
### 发布页优化
|
||||
- 刷新回到顶部(发布页)
|
||||
|
||||
---
|
||||
|
||||
## 🎵 字体与背景音乐资源库接入 (15:50)
|
||||
## 🎵 背景音乐链路修复 (15:00)
|
||||
|
||||
### 资源库
|
||||
- `backend/assets/fonts/`(SuperIPAgent 字体全量导入)
|
||||
- `backend/assets/bgm/`(背景音乐素材)
|
||||
- `backend/assets/styles/{subtitle.json,title.json}`(样式预设)
|
||||
|
||||
### 服务能力
|
||||
- `/api/assets/subtitle-styles`、`/api/assets/title-styles`、`/api/assets/bgm`
|
||||
- `/assets` 静态挂载供前端预览与试听
|
||||
|
||||
### 生成链路调整
|
||||
- 先完成人声与唇形/字幕对齐,再混入 BGM
|
||||
- 修复 FFmpeg shell 解析导致的混音失败
|
||||
- 禁用 amix 归一化,保证配音音量不被压低
|
||||
### 修复点
|
||||
- FFmpeg 混音改为 `shell=False`,避免 `filter_complex` 被 shell 误解析
|
||||
- `amix` 禁用归一化,避免配音音量被压低
|
||||
|
||||
### 关键修改
|
||||
`backend/app/services/video_service.py`
|
||||
```python
|
||||
filter_complex = (
|
||||
"[0:a]volume=1.0[a0];"
|
||||
f"[1:a]volume={volume}[a1];"
|
||||
"[a0][a1]amix=inputs=2:duration=first:dropout_transition=2:normalize=0[aout]"
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🖼️ 标题/字幕样式预览 (16:10)
|
||||
## 🗣️ 字幕断句修复 (15:20)
|
||||
|
||||
### 前端
|
||||
- 样式选择 + 预览面板
|
||||
- 字号可调(覆盖样式默认值)
|
||||
- 字体文件动态加载
|
||||
### 内容
|
||||
- 字幕切分逻辑保留英文单词整体,避免中英混合被硬切
|
||||
|
||||
### Remotion
|
||||
- 样式参数透传到 `Subtitles` / `Title`
|
||||
- 渲染前临时复制字体到渲染目录
|
||||
### 涉及文件
|
||||
- `backend/app/services/whisper_service.py`
|
||||
|
||||
---
|
||||
|
||||
## 🧱 资源库与样式能力接入 (15:40)
|
||||
|
||||
### 内容
|
||||
- 字体库 / BGM 资源接入本地 assets
|
||||
- 新增样式配置文件(字幕/标题)
|
||||
- 新增资源 API 与静态挂载 `/assets`
|
||||
- Remotion 支持样式参数与字体加载
|
||||
|
||||
### 涉及文件
|
||||
- `backend/assets/fonts/`
|
||||
- `backend/assets/bgm/`
|
||||
- `backend/assets/styles/subtitle.json`
|
||||
- `backend/assets/styles/title.json`
|
||||
- `backend/app/services/assets_service.py`
|
||||
- `backend/app/api/assets.py`
|
||||
- `backend/app/main.py`
|
||||
- `backend/app/api/videos.py`
|
||||
- `backend/app/services/remotion_service.py`
|
||||
- `remotion/src/components/Subtitles.tsx`
|
||||
- `remotion/src/components/Title.tsx`
|
||||
- `remotion/src/Video.tsx`
|
||||
- `remotion/render.ts`
|
||||
- `frontend/src/app/page.tsx`
|
||||
- `frontend/next.config.ts`
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ 运维调整 (16:10)
|
||||
|
||||
### 内容
|
||||
- Watchdog 移除 LatentSync 监控,避免长推理误杀
|
||||
- LatentSync PM2 增加内存重启阈值(运行时配置)
|
||||
|
||||
---
|
||||
|
||||
## 🎯 前端按钮图标统一 (16:40)
|
||||
|
||||
### 内容
|
||||
- 首页与发布页按钮图标统一替换为 Lucide SVG
|
||||
- 交互按钮保持一致尺寸与对齐
|
||||
|
||||
### 涉及文件
|
||||
- `frontend/src/components/home/`
|
||||
- `frontend/src/app/publish/page.tsx`
|
||||
|
||||
---
|
||||
|
||||
@@ -114,6 +137,3 @@ filter_complex = (
|
||||
- [x] `Docs/QWEN3_TTS_DEPLOY.md`: 添加 Flash Attention 安装指南
|
||||
- [x] `Docs/DEPLOY_MANUAL.md`: 添加 Watchdog 部署说明
|
||||
- [x] `Docs/task_complete.md`: 更新进度至 100% (Day 16)
|
||||
- [x] `README.md`: 新增样式与背景音乐能力说明
|
||||
- [x] `Docs/BACKEND_README.md`: 资产接口与混音链路说明
|
||||
- [x] `Docs/FRONTEND_README.md`: 新增样式预览与BGM试听说明
|
||||
|
||||
155
Docs/DevLogs/Day17.md
Normal file
155
Docs/DevLogs/Day17.md
Normal file
@@ -0,0 +1,155 @@
|
||||
# Day 17 - 前端重构与体验优化
|
||||
|
||||
## 🧩 前端 UI 拆分 (09:10)
|
||||
|
||||
### 内容
|
||||
- 首页 `page.tsx` 拆分为独立 UI 组件,状态与逻辑仍集中在页面
|
||||
- 新增首页组件目录 `frontend/src/components/home/`
|
||||
|
||||
### 组件列表
|
||||
- `HomeHeader`
|
||||
- `MaterialSelector`
|
||||
- `ScriptEditor`
|
||||
- `TitleSubtitlePanel`
|
||||
- `VoiceSelector`
|
||||
- `RefAudioPanel`
|
||||
- `BgmPanel`
|
||||
- `GenerateActionBar`
|
||||
- `PreviewPanel`
|
||||
- `HistoryList`
|
||||
|
||||
---
|
||||
|
||||
## 🧰 前端通用工具抽取 (09:30)
|
||||
|
||||
### 内容
|
||||
- 抽取 API Base / 资源 URL / 日期格式化等通用工具
|
||||
- 首页与发布页统一调用,消除重复逻辑
|
||||
|
||||
### 涉及文件
|
||||
- `frontend/src/lib/media.ts`
|
||||
- `frontend/src/app/page.tsx`
|
||||
- `frontend/src/app/publish/page.tsx`
|
||||
|
||||
---
|
||||
|
||||
## 📝 前端规范更新 (09:40)
|
||||
|
||||
### 内容
|
||||
- 更新 `FRONTEND_DEV.md` 以匹配最新目录结构
|
||||
- 新增 `media.ts` 使用规范与示例
|
||||
- 增加组件拆分规范与页面 checklist
|
||||
|
||||
### 涉及文件
|
||||
- `Docs/FRONTEND_DEV.md`
|
||||
|
||||
---
|
||||
|
||||
## 🎨 交互体验与视图优化 (10:00)
|
||||
|
||||
### 标题/字幕预览
|
||||
- 标题/字幕预览按素材分辨率缩放,字号更接近成片
|
||||
- 标题/字幕样式选择持久化,刷新不回默认
|
||||
- 默认样式更新:标题 90px 站酷快乐体,字幕 60px 经典黄字 + DingTalkJinBuTi
|
||||
|
||||
### 发布页优化
|
||||
- 选择作品改为卡片列表 + 搜索 + 预览弹窗
|
||||
|
||||
---
|
||||
|
||||
## ⚡ 性能微优化 (10:30)
|
||||
|
||||
### 内容
|
||||
- 列表渲染启用 `content-visibility`(素材/历史/参考音频/发布作品),BGM 列表保留滚动定位
|
||||
- 首屏数据请求并行化(`Promise.allSettled`)
|
||||
- localStorage 写入防抖(文本/标题/BGM 音量/发布表单)
|
||||
|
||||
---
|
||||
|
||||
## 🖼️ 预览弹窗增强 (11:10)
|
||||
|
||||
### 内容
|
||||
- 预览弹窗统一为可复用组件,支持标题与提示
|
||||
- 发布页预览与素材预览共享弹窗样式
|
||||
- 弹窗头部样式统一(图标 + 标题 + 关闭按钮)
|
||||
|
||||
### 涉及文件
|
||||
- `frontend/src/components/VideoPreviewModal.tsx`
|
||||
- `frontend/src/app/page.tsx`
|
||||
- `frontend/src/app/publish/page.tsx`
|
||||
|
||||
---
|
||||
|
||||
## 🧭 术语统一 (11:20)
|
||||
|
||||
### 内容
|
||||
- “视频预览” → “作品预览”
|
||||
- “历史视频” → “历史作品”
|
||||
- “选择要发布的视频” → “选择要发布的作品”
|
||||
- “选择素材视频” → “视频素材”
|
||||
- “选择配音方式” → “配音方式”
|
||||
|
||||
---
|
||||
|
||||
## 🧱 Phase 2 Hook 抽取 (11:45)
|
||||
|
||||
### 内容
|
||||
- `useTitleSubtitleStyles`:标题/字幕样式获取与默认选择逻辑
|
||||
- `useMaterials`:素材列表/上传/删除逻辑抽取
|
||||
- `useRefAudios`:参考音频列表/上传/删除逻辑抽取
|
||||
- `useBgm`:背景音乐列表与加载状态抽取
|
||||
- `useMediaPlayers`:音频试听逻辑集中管理(参考音频/背景音乐)
|
||||
- `useGeneratedVideos`:历史作品列表获取 + 选择逻辑抽取
|
||||
|
||||
### 涉及文件
|
||||
- `frontend/src/hooks/useTitleSubtitleStyles.ts`
|
||||
- `frontend/src/hooks/useMaterials.ts`
|
||||
- `frontend/src/hooks/useRefAudios.ts`
|
||||
- `frontend/src/hooks/useBgm.ts`
|
||||
- `frontend/src/hooks/useMediaPlayers.ts`
|
||||
- `frontend/src/hooks/useGeneratedVideos.ts`
|
||||
- `frontend/src/app/page.tsx`
|
||||
|
||||
---
|
||||
|
||||
## 🧩 首页持久化修复 (12:20)
|
||||
|
||||
### 内容
|
||||
- 接入 `useHomePersistence`,补齐 `isRestored` 恢复/保存逻辑
|
||||
- 修复首页刷新后选择项恢复链路,`npm run build` 通过
|
||||
|
||||
### 涉及文件
|
||||
- `frontend/src/app/page.tsx`
|
||||
- `frontend/src/hooks/useHomePersistence.ts`
|
||||
|
||||
---
|
||||
|
||||
## 🧩 发布预览与播放修复 (14:10)
|
||||
|
||||
### 内容
|
||||
- 发布页作品预览兼容签名 URL 与相对路径
|
||||
- 参考音频试听统一走 `resolveMediaUrl`
|
||||
- 素材/BGM 选择在列表变化时自动回退有效项
|
||||
- 录音预览 URL 回收、预览弹窗滚动状态恢复、全局任务提示挂载
|
||||
|
||||
### 涉及文件
|
||||
- `frontend/src/app/publish/page.tsx`
|
||||
- `frontend/src/hooks/useMediaPlayers.ts`
|
||||
- `frontend/src/hooks/useBgm.ts`
|
||||
- `frontend/src/hooks/useMaterials.ts`
|
||||
- `frontend/src/components/home/RefAudioPanel.tsx`
|
||||
- `frontend/src/components/VideoPreviewModal.tsx`
|
||||
- `frontend/src/app/layout.tsx`
|
||||
|
||||
---
|
||||
|
||||
## 🧩 标题同步与长度限制 (15:30)
|
||||
|
||||
### 内容
|
||||
- 片头标题修改同步写入发布信息标题
|
||||
- 标题输入兼容中文输入法,限制 15 字(发布信息同规则)
|
||||
|
||||
### 涉及文件
|
||||
- `frontend/src/app/page.tsx`
|
||||
- `frontend/src/components/home/TitleSubtitlePanel.tsx`
|
||||
- `frontend/src/app/publish/page.tsx`
|
||||
@@ -24,11 +24,12 @@
|
||||
| :---: | :--- | :--- |
|
||||
| 🔥 **High** | `Docs/DevLogs/DayN.md` | **(最新日志)** 详细记录变更、修复、代码片段 |
|
||||
| 🔥 **High** | `Docs/task_complete.md` | **(任务总览)** 更新 `[x]`、进度条、时间线 |
|
||||
| ⚡ **Med** | `README.md` | **(项目主页)** 功能特性、技术栈、最新截图 |
|
||||
| ⚡ **Med** | `Docs/DEPLOY_MANUAL.md` | **(部署手册)** 环境变量、依赖包、启动命令变更 |
|
||||
| ⚡ **Med** | `Docs/FRONTEND_DEV.md` | **(前端规范)** API封装、日期格式化、新页面规范 |
|
||||
| 🧊 **Low** | `Docs/implementation_plan.md` | **(实施计划)** 核对计划与实际实现的差异 |
|
||||
| 🧊 **Low** | `frontend/README.md` | **(前端文档)** 新页面路由、组件用法、UI变更 |
|
||||
| ⚡ **Med** | `README.md` | **(项目主页)** 功能特性、技术栈、最新截图 |
|
||||
| ⚡ **Med** | `Docs/DEPLOY_MANUAL.md` | **(部署手册)** 环境变量、依赖包、启动命令变更 |
|
||||
| ⚡ **Med** | `Docs/FRONTEND_DEV.md` | **(前端规范)** API封装、日期格式化、新页面规范 |
|
||||
| ⚡ **Med** | `Docs/FRONTEND_README.md` | **(前端文档)** 功能说明、页面变更 |
|
||||
| 🧊 **Low** | `Docs/implementation_plan.md` | **(实施计划)** 核对计划与实际实现的差异 |
|
||||
| 🧊 **Low** | `Docs/architecture_plan.md` | **(前端架构)** 拆分计划与阶段目标 |
|
||||
|
||||
---
|
||||
|
||||
@@ -140,20 +141,20 @@
|
||||
|
||||
> **核心原则**:使用正确的工具,避免字符编码问题
|
||||
|
||||
### ✅ 推荐工具:replace_file_content
|
||||
### ✅ 推荐工具:apply_patch
|
||||
|
||||
**使用场景**:
|
||||
**使用场景**:
|
||||
- 追加新章节到文件末尾
|
||||
- 修改/替换现有章节内容
|
||||
- 更新状态标记(🔄 → ✅)
|
||||
- 修正错误内容
|
||||
|
||||
**优势**:
|
||||
**优势**:
|
||||
- ✅ 自动处理字符编码(Windows CRLF)
|
||||
- ✅ 精确替换,不会误删其他内容
|
||||
- ✅ 有错误提示,方便调试
|
||||
|
||||
**注意事项**:
|
||||
**注意事项**:
|
||||
```markdown
|
||||
1. **必须精确匹配**:TargetContent 必须与文件完全一致
|
||||
2. **处理换行符**:文件使用 \r\n,不要漏掉 \r
|
||||
@@ -177,39 +178,45 @@
|
||||
|
||||
### 📝 最佳实践示例
|
||||
|
||||
**追加新章节**:
|
||||
```python
|
||||
replace_file_content(
|
||||
TargetFile="path/to/DayN.md",
|
||||
TargetContent="## 🔗 相关文档\n\n...\n\n", # 文件末尾的内容
|
||||
ReplacementContent="## 🔗 相关文档\n\n...\n\n---\n\n## 🆕 新章节\n内容...",
|
||||
StartLine=280,
|
||||
EndLine=284
|
||||
)
|
||||
```
|
||||
**追加新章节**:
|
||||
```diff
|
||||
*** Begin Patch
|
||||
*** Update File: Docs/DevLogs/DayN.md
|
||||
@@
|
||||
## 🔗 相关文档
|
||||
|
||||
...
|
||||
---
|
||||
|
||||
## 🆕 新章节
|
||||
内容...
|
||||
*** End Patch
|
||||
```
|
||||
|
||||
**修改现有内容**:
|
||||
```python
|
||||
replace_file_content(
|
||||
TargetContent="**状态**:🔄 待修复",
|
||||
ReplacementContent="**状态**:✅ 已修复",
|
||||
StartLine=310,
|
||||
EndLine=310
|
||||
)
|
||||
```
|
||||
**修改现有内容**:
|
||||
```diff
|
||||
*** Begin Patch
|
||||
*** Update File: Docs/DevLogs/DayN.md
|
||||
@@
|
||||
-**状态**:🔄 待修复
|
||||
+**状态**:✅ 已修复
|
||||
*** End Patch
|
||||
```
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 📁 文件结构
|
||||
## 📁 文件结构
|
||||
|
||||
```
|
||||
ViGent/Docs/
|
||||
├── task_complete.md # 任务总览(仅按需更新)
|
||||
├── Doc_Rules.md # 本文件
|
||||
├── FRONTEND_DEV.md # 前端开发规范
|
||||
├── DEPLOY_MANUAL.md # 部署手册
|
||||
├── SUPABASE_DEPLOY.md # Supabase 部署文档
|
||||
ViGent2/Docs/
|
||||
├── task_complete.md # 任务总览(仅按需更新)
|
||||
├── Doc_Rules.md # 本文件
|
||||
├── FRONTEND_DEV.md # 前端开发规范
|
||||
├── FRONTEND_README.md # 前端功能文档
|
||||
├── architecture_plan.md # 前端拆分计划
|
||||
├── DEPLOY_MANUAL.md # 部署手册
|
||||
├── SUPABASE_DEPLOY.md # Supabase 部署文档
|
||||
└── DevLogs/
|
||||
├── Day1.md # 开发日志
|
||||
└── ...
|
||||
@@ -217,7 +224,7 @@ ViGent/Docs/
|
||||
|
||||
---
|
||||
|
||||
## 📅 DayN.md 更新规则(日常更新)
|
||||
## 📅 DayN.md 更新规则(日常更新)
|
||||
|
||||
### 新建判断 (对话开始前)
|
||||
1. **回顾进度**:查看 `task_complete.md` 了解当前状态
|
||||
@@ -225,9 +232,9 @@ ViGent/Docs/
|
||||
- **今天 (与当前日期相同)** → 🚨 **绝对禁止创建新文件**,必须**追加**到现有 `DayN.md` 末尾!即使是完全不同的功能模块。
|
||||
- **之前 (昨天或更早)** → 创建 `Day{N+1}.md`
|
||||
|
||||
### 追加格式
|
||||
```markdown
|
||||
---
|
||||
### 追加格式
|
||||
```markdown
|
||||
---
|
||||
|
||||
## 🔧 [章节标题]
|
||||
|
||||
@@ -243,14 +250,18 @@ ViGent/Docs/
|
||||
- ✅ 修复了 xxx
|
||||
```
|
||||
|
||||
### 快速修复格式
|
||||
```markdown
|
||||
## 🐛 [Bug 简述] (HH:MM)
|
||||
### 快速修复格式
|
||||
```markdown
|
||||
## 🐛 [Bug 简述] (HH:MM)
|
||||
|
||||
**问题**:一句话描述
|
||||
**修复**:修改了 `文件名` 中的 xxx
|
||||
**状态**:✅ 已修复 / 🔄 待验证
|
||||
```
|
||||
**状态**:✅ 已修复 / 🔄 待验证
|
||||
```
|
||||
|
||||
### ⚠️ 注意
|
||||
- **DayN.md 文件开头禁止使用 `---`**,避免被解析为 Front Matter。
|
||||
- 分隔线只用于章节之间,不作为文件第一行。
|
||||
|
||||
---
|
||||
|
||||
@@ -305,4 +316,4 @@ ViGent/Docs/
|
||||
|
||||
---
|
||||
|
||||
**最后更新**:2026-01-23
|
||||
**最后更新**:2026-02-04
|
||||
|
||||
@@ -10,9 +10,13 @@ frontend/src/
|
||||
│ ├── admin/ # 管理员页面
|
||||
│ ├── login/ # 登录页面
|
||||
│ └── register/ # 注册页面
|
||||
├── components/ # 可复用组件
|
||||
│ ├── home/ # 首页拆分组件
|
||||
│ └── ...
|
||||
├── lib/ # 公共工具函数
|
||||
│ ├── axios.ts # Axios 实例(含 401/403 拦截器)
|
||||
│ └── auth.ts # 认证相关函数
|
||||
│ ├── auth.ts # 认证相关函数
|
||||
│ └── media.ts # API Base / URL / 日期等通用工具
|
||||
└── proxy.ts # 路由代理(原 middleware)
|
||||
```
|
||||
|
||||
@@ -146,6 +150,27 @@ const { data } = useSWR('/api/xxx', fetcher, { refreshInterval: 2000 });
|
||||
|
||||
---
|
||||
|
||||
## 通用工具函数 (media.ts)
|
||||
|
||||
### 统一 API Base / URL 解析
|
||||
使用 `@/lib/media` 统一处理服务端/客户端 API Base 与资源地址,避免硬编码:
|
||||
|
||||
```typescript
|
||||
import { getApiBaseUrl, resolveMediaUrl, resolveAssetUrl, formatDate } from '@/lib/media';
|
||||
|
||||
const apiBase = getApiBaseUrl(); // SSR: http://localhost:8006 / Client: ''
|
||||
const playableUrl = resolveMediaUrl(video.path); // 兼容签名 URL 与相对路径
|
||||
const fontUrl = resolveAssetUrl(`fonts/${fontFile}`);
|
||||
const timeText = formatDate(video.created_at);
|
||||
```
|
||||
|
||||
### 资源路径规则
|
||||
- 视频/音频:优先用 `resolveMediaUrl()`
|
||||
- 字体/BGM:使用 `resolveAssetUrl()`(自动编码中文路径)
|
||||
- 预览前若已有签名 URL,先用 `isAbsoluteUrl()` 判定,避免再次拼接
|
||||
|
||||
---
|
||||
|
||||
## 日期格式化规范
|
||||
|
||||
### 禁止使用 `toLocaleString()`
|
||||
@@ -161,25 +186,56 @@ new Date(timestamp * 1000).toLocaleString('zh-CN')
|
||||
**正确做法:**
|
||||
```typescript
|
||||
// ✅ 使用固定格式
|
||||
const formatDate = (timestamp: number) => {
|
||||
const d = new Date(timestamp * 1000);
|
||||
const year = d.getFullYear();
|
||||
const month = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
const hour = String(d.getHours()).padStart(2, '0');
|
||||
const minute = String(d.getMinutes()).padStart(2, '0');
|
||||
return `${year}/${month}/${day} ${hour}:${minute}`;
|
||||
};
|
||||
import { formatDate } from '@/lib/media';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 组件拆分规范
|
||||
|
||||
当页面组件超过 300-500 行,建议拆分到 `components/`:
|
||||
|
||||
- `page.tsx` 负责状态与业务逻辑
|
||||
- 组件只接受 props 与回调,尽量不直接发 API
|
||||
- 首页拆分组件统一放在 `components/home/`
|
||||
|
||||
---
|
||||
|
||||
## 用户偏好持久化
|
||||
|
||||
首页涉及样式与字号等用户偏好时,需持久化并在刷新后恢复:
|
||||
|
||||
- **必须持久化**:
|
||||
- 标题样式 ID / 字幕样式 ID
|
||||
- 标题字号 / 字幕字号
|
||||
- 背景音乐选择 / 音量 / 开关状态
|
||||
- 素材选择 / 历史作品选择
|
||||
|
||||
### 实施规范
|
||||
- 使用 `storageKey = userId || 'guest'`,按用户隔离。
|
||||
- **恢复先于保存**:恢复完成前禁止写入(`isRestored` 保护)。
|
||||
- 避免默认值覆盖用户选择(优先读取已保存值)。
|
||||
- 优先使用 `useHomePersistence` 集中管理恢复/保存,页面内避免分散的 localStorage 读写。
|
||||
- 如需新增持久化字段,必须加入恢复与保存逻辑,并更新本节。
|
||||
|
||||
---
|
||||
|
||||
## 标题输入规则
|
||||
|
||||
- 片头标题与发布信息标题统一限制 15 字。
|
||||
- 中文输入法合成阶段不截断,合成结束后才校验长度。
|
||||
- 首页片头标题修改会同步写入 `vigent_${storageKey}_publish_title`。
|
||||
- 避免使用 `maxLength` 强制截断输入法合成态。
|
||||
|
||||
---
|
||||
|
||||
## 新增页面 Checklist
|
||||
|
||||
1. [ ] 导入 `import api from '@/lib/axios'`
|
||||
2. [ ] 所有 API 请求使用 `api.get/post/delete()` 而非原生 `fetch`
|
||||
3. [ ] 日期格式化使用固定格式函数,不用 `toLocaleString()`
|
||||
4. [ ] 添加 `'use client'` 指令(如需客户端交互)
|
||||
3. [ ] 日期格式化使用 `@/lib/media` 的 `formatDate`
|
||||
4. [ ] 资源 URL 使用 `resolveMediaUrl`/`resolveAssetUrl`
|
||||
5. [ ] 添加 `'use client'` 指令(如需客户端交互)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# ViGent2 Frontend
|
||||
|
||||
ViGent2 的前端界面,采用 Next.js 14 + TailwindCSS 构建。
|
||||
ViGent2 的前端界面,采用 Next.js 16 + TailwindCSS 构建。
|
||||
|
||||
## ✨ 核心功能
|
||||
|
||||
@@ -11,9 +11,10 @@ ViGent2 的前端界面,采用 Next.js 14 + TailwindCSS 构建。
|
||||
- **标题/字幕样式**: 样式选择 + 预览 + 字号调节 (Day 16)。
|
||||
- **背景音乐**: 试听 + 音量控制 + 选择持久化 (Day 16)。
|
||||
- **交互优化**: 选择项持久化、列表内定位、刷新回顶部 (Day 16)。
|
||||
- **预览一致性**: 标题/字幕预览按素材分辨率缩放,效果更接近成片 (Day 17)。
|
||||
- **进度追踪**: 实时显示视频生成进度 (10% -> 100%)。
|
||||
- **结果预览**: 生成完成后直接播放下载。
|
||||
- **本地保存**: 文案/标题自动保存,刷新后恢复 (Day 14)。
|
||||
- **作品预览**: 生成完成后直接播放下载(作品预览 + 历史作品)。
|
||||
- **本地保存**: 文案/标题/偏好由 `useHomePersistence` 统一持久化,刷新后恢复 (Day 14/17)。
|
||||
|
||||
### 2. 全自动发布 (`/publish`) [Day 7 新增]
|
||||
- **多平台管理**: 统一管理 B站、抖音、小红书账号状态。
|
||||
@@ -22,6 +23,8 @@ ViGent2 的前端界面,采用 Next.js 14 + TailwindCSS 构建。
|
||||
- 实时检测扫码状态 (Wait/Success)。
|
||||
- Cookie 自动保存与状态同步。
|
||||
- **发布配置**: 设置视频标题、标签、简介。
|
||||
- **作品选择**: 卡片列表 + 搜索 + 预览弹窗。
|
||||
- **预览兼容**: 签名 URL / 相对路径均可直接预览。
|
||||
- **定时任务**: 支持 "立即发布" 或 "定时发布"。
|
||||
|
||||
### 3. 声音克隆 [Day 13 新增]
|
||||
@@ -30,10 +33,13 @@ ViGent2 的前端界面,采用 Next.js 14 + TailwindCSS 构建。
|
||||
- **一键克隆**: 选择参考音频后自动调用 Qwen3-TTS 服务。
|
||||
|
||||
### 4. 字幕与标题 [Day 13 新增]
|
||||
- **片头标题**: 可选输入,视频开头显示 3 秒淡入淡出标题。
|
||||
- **片头标题**: 可选输入,限制 15 字,视频开头显示 3 秒淡入淡出标题。
|
||||
- **标题同步**: 首页片头标题修改会同步到发布信息标题。
|
||||
- **逐字高亮字幕**: 卡拉OK效果,默认开启,可关闭。
|
||||
- **自动对齐**: 基于 faster-whisper 生成字级别时间戳。
|
||||
- **样式预设**: 标题/字幕样式选择 + 预览 + 字号调节 (Day 16)。
|
||||
- **默认样式**: 标题 90px 站酷快乐体;字幕 60px 经典黄字 + DingTalkJinBuTi (Day 17)。
|
||||
- **样式持久化**: 标题/字幕样式与字号刷新保留 (Day 17)。
|
||||
|
||||
### 5. 背景音乐 [Day 16 新增]
|
||||
- **试听预览**: 点击试听即选中,音量滑块实时生效。
|
||||
@@ -52,7 +58,7 @@ ViGent2 的前端界面,采用 Next.js 14 + TailwindCSS 构建。
|
||||
|
||||
## 🛠️ 技术栈
|
||||
|
||||
- **框架**: Next.js 14 (App Router)
|
||||
- **框架**: Next.js 16 (App Router)
|
||||
- **样式**: TailwindCSS
|
||||
- **图标**: Lucide React
|
||||
- **组件**: 自定义现代化组件 (Glassmorphism 风格)
|
||||
@@ -85,15 +91,16 @@ src/
|
||||
│ │ └── page.tsx
|
||||
│ └── layout.tsx # 全局布局 (导航栏)
|
||||
├── components/ # UI 组件
|
||||
│ ├── VideoUploader.tsx # 视频上传
|
||||
│ ├── StatusBadge.tsx # 状态徽章
|
||||
│ ├── home/ # 首页拆分组件
|
||||
│ └── ...
|
||||
└── lib/ # 工具函数
|
||||
└── media.ts # API Base / URL / 日期等通用工具
|
||||
```
|
||||
|
||||
## 🔌 后端对接
|
||||
|
||||
- **Base URL**: `http://localhost:8006`
|
||||
- **Base URL**: `http://localhost:8006` (SSR) / 相对路径 (Client)
|
||||
- **URL 统一工具**: `@/lib/media` 提供 `resolveMediaUrl` / `resolveAssetUrl`
|
||||
- **代理配置**: Next.js Rewrites (如需) 或直接 CORS。
|
||||
|
||||
## 🎨 设计规范
|
||||
|
||||
@@ -42,17 +42,28 @@
|
||||
|
||||
| 模块 | 技术选择 | 备选方案 |
|
||||
|------|----------|----------|
|
||||
| **前端框架** | Next.js 14 | Vue 3 + Vite |
|
||||
| **UI 组件库** | Tailwind + shadcn/ui | Ant Design |
|
||||
| **后端框架** | FastAPI | Flask |
|
||||
| **任务队列** | Celery + Redis | RQ / Dramatiq |
|
||||
| **唇形同步** | **LatentSync 1.6** | MuseTalk / Wav2Lip |
|
||||
| **TTS 配音** | EdgeTTS | CosyVoice |
|
||||
| **声音克隆** | **Qwen3-TTS 1.7B** ✅ | GPT-SoVITS |
|
||||
| **视频处理** | FFmpeg | MoviePy |
|
||||
| **自动发布** | social-auto-upload | 自行实现 |
|
||||
| **数据库** | SQLite → PostgreSQL | MySQL |
|
||||
| **文件存储** | 本地 / MinIO | 阿里云 OSS |
|
||||
| **前端框架** | Next.js 16 | Vue 3 + Vite |
|
||||
| **UI 组件库** | TailwindCSS (自定义组件) | Ant Design |
|
||||
| **后端框架** | FastAPI | Flask |
|
||||
| **任务队列** | FastAPI BackgroundTasks (asyncio) | Celery + Redis |
|
||||
| **唇形同步** | **LatentSync 1.6** | MuseTalk / Wav2Lip |
|
||||
| **TTS 配音** | EdgeTTS | CosyVoice |
|
||||
| **声音克隆** | **Qwen3-TTS 1.7B** ✅ | GPT-SoVITS |
|
||||
| **视频处理** | FFmpeg | MoviePy |
|
||||
| **自动发布** | Playwright | 自行实现 |
|
||||
| **数据库** | Supabase (PostgreSQL) | MySQL |
|
||||
| **文件存储** | Supabase Storage | 阿里云 OSS |
|
||||
|
||||
> **修正 (18:10)**:当前实现采用 Next.js 16、FastAPI BackgroundTasks 与 Supabase Storage/Auth,自动发布基于 Playwright。
|
||||
|
||||
---
|
||||
|
||||
## ✅ 现状补充 (Day 17)
|
||||
|
||||
- 前端已拆分为组件化结构(`components/home/`),主页面逻辑集中。
|
||||
- 通用工具 `media.ts` 统一处理 API Base / 资源 URL / 日期格式化。
|
||||
- 作品预览弹窗统一样式,并支持素材/发布预览复用。
|
||||
- 标题/字幕预览按素材分辨率缩放,效果更接近成片。
|
||||
|
||||
---
|
||||
|
||||
@@ -60,24 +71,11 @@
|
||||
|
||||
### 阶段一:核心功能验证 (MVP)
|
||||
|
||||
> **目标**:验证 MuseTalk + EdgeTTS 效果,跑通端到端流程
|
||||
> **目标**:验证 LatentSync + EdgeTTS 效果,跑通端到端流程
|
||||
|
||||
#### 1.1 环境搭建
|
||||
|
||||
```bash
|
||||
# 创建项目目录
|
||||
mkdir TalkingHeadAgent
|
||||
cd TalkingHeadAgent
|
||||
|
||||
# 克隆 MuseTalk
|
||||
git clone https://github.com/TMElyralab/MuseTalk.git
|
||||
|
||||
# 安装依赖
|
||||
cd MuseTalk
|
||||
pip install -r requirements.txt
|
||||
|
||||
# 下载模型权重 (按官方文档)
|
||||
```
|
||||
#### 1.1 环境搭建
|
||||
|
||||
参考 `models/LatentSync/DEPLOY.md` 完成 LatentSync 环境与权重部署。
|
||||
|
||||
#### 1.2 集成 EdgeTTS
|
||||
|
||||
@@ -98,13 +96,13 @@ async def text_to_speech(text: str, voice: str = "zh-CN-YunxiNeural", output_pat
|
||||
# test_pipeline.py
|
||||
"""
|
||||
1. 文案 → EdgeTTS → 音频
|
||||
2. 静态视频 + 音频 → MuseTalk → 口播视频
|
||||
2. 静态视频 + 音频 → LatentSync → 口播视频
|
||||
3. 添加字幕 → FFmpeg → 最终视频
|
||||
"""
|
||||
```
|
||||
|
||||
#### 1.4 验证标准
|
||||
- [ ] MuseTalk 能正常推理
|
||||
- [ ] LatentSync 能正常推理
|
||||
- [ ] 唇形与音频同步率 > 90%
|
||||
- [ ] 单个视频生成时间 < 2 分钟
|
||||
|
||||
@@ -142,25 +140,19 @@ backend/
|
||||
|
||||
| 端点 | 方法 | 功能 |
|
||||
|------|------|------|
|
||||
| `/api/materials` | POST | 上传素材视频 | ✅ |
|
||||
| `/api/materials` | POST | 上传视频素材 | ✅ |
|
||||
| `/api/materials` | GET | 获取素材列表 | ✅ |
|
||||
| `/api/videos/generate` | POST | 创建视频生成任务 | ✅ |
|
||||
| `/api/tasks/{id}` | GET | 查询任务状态 | ✅ |
|
||||
| `/api/videos/{id}/download` | GET | 下载生成的视频 | ✅ |
|
||||
| `/api/videos/tasks/{id}` | GET | 查询任务状态 | ✅ |
|
||||
| `/api/videos/generated` | GET | 获取历史作品列表 | ✅ |
|
||||
| `/api/publish` | POST | 发布到社交平台 | ✅ |
|
||||
|
||||
#### 2.3 Celery 任务定义
|
||||
|
||||
```python
|
||||
# tasks/celery_tasks.py
|
||||
@celery.task
|
||||
def generate_video_task(material_id: str, text: str, voice: str):
|
||||
# 1. TTS 生成音频
|
||||
# 2. MuseTalk 唇形同步
|
||||
# 3. FFmpeg 添加字幕
|
||||
# 4. 保存并返回视频 URL
|
||||
pass
|
||||
```
|
||||
#### 2.3 BackgroundTasks 任务定义
|
||||
|
||||
```python
|
||||
# app/api/videos.py
|
||||
background_tasks.add_task(_process_video_generation, task_id, req, user_id)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -172,7 +164,7 @@ def generate_video_task(material_id: str, text: str, voice: str):
|
||||
|
||||
| 页面 | 功能 |
|
||||
|------|------|
|
||||
| **素材库** | 上传/管理多场景素材视频 |
|
||||
| **素材库** | 上传/管理多场景视频素材 |
|
||||
| **生成视频** | 输入文案、选择素材、生成预览 |
|
||||
| **任务中心** | 查看生成进度、下载视频 |
|
||||
| **发布管理** | 绑定平台、一键发布、定时发布 |
|
||||
@@ -183,9 +175,9 @@ def generate_video_task(material_id: str, text: str, voice: str):
|
||||
# 创建 Next.js 项目
|
||||
npx create-next-app@latest frontend --typescript --tailwind --app
|
||||
|
||||
# 安装依赖
|
||||
cd frontend
|
||||
npm install @tanstack/react-query axios
|
||||
# 安装依赖
|
||||
cd frontend
|
||||
npm install axios swr
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# ViGent2 开发任务清单 (Task Log)
|
||||
|
||||
**项目**: ViGent2 数字人口播视频生成系统
|
||||
**进度**: 100% (Day 16 - 深度优化完成)
|
||||
**更新时间**: 2026-02-03
|
||||
**进度**: 100% (Day 17 - 前端重构与体验优化)
|
||||
**更新时间**: 2026-02-04
|
||||
|
||||
---
|
||||
|
||||
@@ -10,15 +10,26 @@
|
||||
|
||||
> 这里记录了每一天的核心开发内容与 milestone。
|
||||
|
||||
### Day 16: 深度性能优化 (Current) 🚀
|
||||
### Day 17: 前端重构与体验优化 (Current) 🚀
|
||||
- [x] **UI 组件拆分**: 首页拆分为独立组件,降低 `page.tsx` 复杂度。
|
||||
- [x] **通用工具抽取**: `media.ts` 统一 API Base / URL / 日期格式化。
|
||||
- [x] **交互优化**: 选择项持久化、列表内定位、刷新回顶部、最新作品优先预览。
|
||||
- [x] **发布页改造**: 作品列表卡片化 + 搜索 + 预览弹窗。
|
||||
- [x] **预览体验**: 预览弹窗统一头部样式与提示文案。
|
||||
- [x] **预览一致性**: 标题/字幕预览按素材分辨率缩放。
|
||||
- [x] **样式默认与持久化**: 默认样式与字号调整,刷新保留用户选择。
|
||||
- [x] **性能微优化**: 列表渲染优化 + 并行请求 + localStorage 防抖。
|
||||
- [x] **资源能力**: 字体/BGM 资源库 + `/api/assets` 接入。
|
||||
- [x] **音频与字幕修复**: BGM 混音稳定性与字幕断句优化。
|
||||
- [x] **持久化修复**: 接入 `useHomePersistence`,恢复 `isRestored` 逻辑并通过构建。
|
||||
- [x] **预览与选择修复**: 发布预览兼容签名 URL,音频试听路径解析,素材/BGM 回退有效项。
|
||||
- [x] **体验细节优化**: 录音预览 URL 回收,预览弹窗滚动恢复,全局任务提示挂载。
|
||||
|
||||
### Day 16: 深度性能优化
|
||||
- [x] **Qwen-TTS 加速**: 集成 Flash Attention 2,模型加载速度提升至 8.9s。
|
||||
- [x] **服务守护**: 开发 `Watchdog` 看门狗机制,自动监控并重启僵死服务。
|
||||
- [x] **LatentSync 性能确认**: 验证 DeepCache + 原生 Flash Attn 生效。
|
||||
- [x] **文档重构**: 全面更新 README、部署手册及后端文档。
|
||||
- [x] **UI 交互优化**: 选择项持久化、列表内定位、刷新回顶部。
|
||||
- [x] **样式与预览**: 标题/字幕样式选择 + 预览 + 字号调节。
|
||||
- [x] **背景音乐**: 试听 + 音量控制 + 混音稳定性修复。
|
||||
- [x] **资产库接入**: 字体/BGM 资源库 + `/api/assets` 资源接口。
|
||||
|
||||
### Day 15: 手机号认证迁移
|
||||
- [x] **认证系统升级**: 从邮箱迁移至 11 位手机号注册/登录。
|
||||
|
||||
11
README.md
11
README.md
@@ -20,14 +20,17 @@
|
||||
- 🎙️ **多模态配音** - 支持 **EdgeTTS** (微软超自然语音) 和 **Qwen3-TTS** (3秒极速声音克隆)。
|
||||
- 📝 **智能字幕** - 集成 faster-whisper + Remotion,自动生成逐字高亮 (卡拉OK效果) 字幕。
|
||||
- 🎨 **样式预设** - 标题/字幕样式选择 + 预览 + 字号调节,支持自定义字体库。
|
||||
- 🖼️ **作品预览一致性** - 标题/字幕预览按素材分辨率缩放,效果更接近成片。
|
||||
- 💾 **用户偏好持久化** - 首页状态统一恢复/保存,刷新后延续上次配置。
|
||||
- 🎵 **背景音乐** - 试听 + 音量控制 + 混音,保持配音音量稳定。
|
||||
- 🤖 **AI 辅助创作** - 内置 GLM-4.7-Flash,支持 B站/抖音链接文案提取、AI 洗稿、标题/标签自动生成。
|
||||
|
||||
### 平台化功能
|
||||
- 📱 **全自动发布** - 支持 B站、抖音、小红书定时发布,扫码登录 + Cookie 持久化。
|
||||
- 🔐 **企业级认证** - 完善的用户隔离系统 (Supabase),支持手机号注册/登录、密码管理。
|
||||
- 📱 **全自动发布** - 支持 B站、抖音、小红书定时发布,扫码登录 + Cookie 持久化。
|
||||
- 🖥️ **发布管理预览** - 支持签名 URL / 相对路径作品预览,确保可直接播放。
|
||||
- 🔐 **认证与隔离** - 基于 Supabase 的用户隔离,支持手机号注册/登录、密码管理。
|
||||
- 🛡️ **服务守护** - 内置 Watchdog 看门狗机制,自动监控并重启僵死服务,确保 7x24h 稳定运行。
|
||||
- 🚀 **极致性能** - 视频预压缩、模型常驻服务 (0s加载)、双 GPU 流水线并发。
|
||||
- 🚀 **性能优化** - 视频预压缩、模型常驻服务(近实时加载)、双 GPU 流水线并发。
|
||||
|
||||
---
|
||||
|
||||
@@ -35,7 +38,7 @@
|
||||
|
||||
| 领域 | 核心技术 | 说明 |
|
||||
|------|----------|------|
|
||||
| **前端** | Next.js 14 | TypeScript, TailwindCSS, SWR |
|
||||
| **前端** | Next.js 16 | TypeScript, TailwindCSS, SWR |
|
||||
| **后端** | FastAPI | Python 3.10, AsyncIO, PM2 |
|
||||
| **数据库** | Supabase | PostgreSQL, Storage (本地/S3), Auth |
|
||||
| **唇形同步** | LatentSync 1.6 | PyTorch 2.5, Diffusers, DeepCache |
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
{
|
||||
"id": "subtitle_classic_yellow",
|
||||
"label": "经典黄字",
|
||||
"font_file": "title/思源黑体/SourceHanSansCN-Bold思源黑体免费.otf",
|
||||
"font_family": "SourceHanSansCN-Bold",
|
||||
"font_size": 52,
|
||||
"font_file": "DingTalk JinBuTi.ttf",
|
||||
"font_family": "DingTalkJinBuTi",
|
||||
"font_size": 60,
|
||||
"highlight_color": "#FFE600",
|
||||
"normal_color": "#FFFFFF",
|
||||
"stroke_color": "#000000",
|
||||
|
||||
@@ -1,4 +1,18 @@
|
||||
[
|
||||
{
|
||||
"id": "title_pop",
|
||||
"label": "站酷快乐体",
|
||||
"font_file": "title/站酷快乐体.ttf",
|
||||
"font_family": "ZCoolHappy",
|
||||
"font_size": 90,
|
||||
"color": "#FFFFFF",
|
||||
"stroke_color": "#000000",
|
||||
"stroke_size": 8,
|
||||
"letter_spacing": 5,
|
||||
"top_margin": 62,
|
||||
"font_weight": 900,
|
||||
"is_default": true
|
||||
},
|
||||
{
|
||||
"id": "title_bold_white",
|
||||
"label": "黑体大标题",
|
||||
@@ -11,7 +25,7 @@
|
||||
"letter_spacing": 4,
|
||||
"top_margin": 60,
|
||||
"font_weight": 900,
|
||||
"is_default": true
|
||||
"is_default": false
|
||||
},
|
||||
{
|
||||
"id": "title_serif_gold",
|
||||
@@ -40,19 +54,5 @@
|
||||
"top_margin": 60,
|
||||
"font_weight": 900,
|
||||
"is_default": false
|
||||
},
|
||||
{
|
||||
"id": "title_pop",
|
||||
"label": "站酷快乐体",
|
||||
"font_file": "title/站酷快乐体.ttf",
|
||||
"font_family": "ZCoolHappy",
|
||||
"font_size": 74,
|
||||
"color": "#FFFFFF",
|
||||
"stroke_color": "#000000",
|
||||
"stroke_size": 8,
|
||||
"letter_spacing": 5,
|
||||
"top_margin": 62,
|
||||
"font_weight": 900,
|
||||
"is_default": false
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
# ViGent2 Frontend
|
||||
|
||||
ViGent2 的前端界面,采用 Next.js 14 + TailwindCSS 构建。
|
||||
|
||||
## ✨ 核心功能
|
||||
|
||||
### 1. 视频生成 (`/`)
|
||||
- **素材管理**: 拖拽上传人物视频,实时预览。
|
||||
- **文案配音**: 集成 EdgeTTS,支持多音色选择 (云溪 / 晓晓)。
|
||||
- **AI 标题/标签**: 一键生成视频标题与标签 (Day 14)。
|
||||
- **进度追踪**: 实时显示视频生成进度 (10% -> 100%)。
|
||||
- **结果预览**: 生成完成后直接播放下载。
|
||||
- **本地保存**: 文案/标题自动保存,刷新后恢复 (Day 14)。
|
||||
|
||||
### 2. 全自动发布 (`/publish`) [Day 7 新增]
|
||||
- **多平台管理**: 统一管理 B站、抖音、小红书账号状态。
|
||||
- **扫码登录**:
|
||||
- 集成后端 Playwright 生成的 QR Code。
|
||||
- 实时检测扫码状态 (Wait/Success)。
|
||||
- Cookie 自动保存与状态同步。
|
||||
- **发布配置**: 设置视频标题、标签、简介。
|
||||
- **定时任务**: 支持 "立即发布" 或 "定时发布"。
|
||||
|
||||
### 3. 声音克隆 [Day 13 新增]
|
||||
- **TTS 模式选择**: EdgeTTS (预设音色) / 声音克隆 (自定义音色) 切换。
|
||||
- **参考音频管理**: 上传/列表/删除参考音频 (3-20秒 WAV)。
|
||||
- **一键克隆**: 选择参考音频后自动调用 Qwen3-TTS 服务。
|
||||
|
||||
### 4. 字幕与标题 [Day 13 新增]
|
||||
- **片头标题**: 可选输入,视频开头显示 3 秒淡入淡出标题。
|
||||
- **逐字高亮字幕**: 卡拉OK效果,默认开启,可关闭。
|
||||
- **自动对齐**: 基于 faster-whisper 生成字级别时间戳。
|
||||
|
||||
### 5. 账户设置 [Day 15 新增]
|
||||
- **手机号登录**: 11位中国手机号验证登录。
|
||||
- **账户下拉菜单**: 显示有效期 + 修改密码 + 安全退出。
|
||||
- **修改密码**: 弹窗输入当前密码与新密码,修改后强制重新登录。
|
||||
|
||||
### 6. 文案提取助手 (`ScriptExtractionModal`) [Day 15 新增]
|
||||
- **多源提取**: 支持文件拖拽上传与 URL 粘贴 (B站/抖音/TikTok)。
|
||||
- **AI 洗稿**: 集成 GLM-4.7-Flash,自动改写为口播文案。
|
||||
- **一键填入**: 提取结果直接填充至视频生成输入框。
|
||||
- **智能交互**: 实时进度展示,防误触设计。
|
||||
|
||||
## 🛠️ 技术栈
|
||||
|
||||
- **框架**: Next.js 14 (App Router)
|
||||
- **样式**: TailwindCSS
|
||||
- **图标**: Lucide React
|
||||
- **组件**: 自定义现代化组件 (Glassmorphism 风格)
|
||||
- **API**: Axios 实例 `@/lib/axios` (对接后端 FastAPI :8006)
|
||||
|
||||
## 🚀 开发指南
|
||||
|
||||
### 安装依赖
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
### 启动开发服务器
|
||||
|
||||
默认运行在 **3002** 端口 (通过 `package.json` 配置):
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# 访问: http://localhost:3002
|
||||
```
|
||||
|
||||
### 目录结构
|
||||
|
||||
```
|
||||
src/
|
||||
├── app/
|
||||
│ ├── page.tsx # 视频生成主页
|
||||
│ ├── publish/ # 发布管理页
|
||||
│ │ └── page.tsx
|
||||
│ └── layout.tsx # 全局布局 (导航栏)
|
||||
├── components/ # UI 组件
|
||||
│ ├── VideoUploader.tsx # 视频上传
|
||||
│ ├── StatusBadge.tsx # 状态徽章
|
||||
│ └── ...
|
||||
└── lib/ # 工具函数
|
||||
```
|
||||
|
||||
## 🔌 后端对接
|
||||
|
||||
- **Base URL**: `http://localhost:8006`
|
||||
- **代理配置**: Next.js Rewrites (如需) 或直接 CORS。
|
||||
|
||||
## 🎨 设计规范
|
||||
|
||||
- **主色调**: 深紫/黑色系 (Dark Mode)
|
||||
- **交互**: 悬停微动画 (Hover Effects)
|
||||
- **响应式**: 适配桌面端大屏操作
|
||||
@@ -39,6 +39,7 @@ export default function RootLayout({
|
||||
>
|
||||
<AuthProvider>
|
||||
<TaskProvider>
|
||||
<GlobalTaskIndicator />
|
||||
{children}
|
||||
</TaskProvider>
|
||||
</AuthProvider>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,30 +1,32 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import useSWR from 'swr';
|
||||
import Link from "next/link";
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import useSWR from 'swr';
|
||||
import Link from "next/link";
|
||||
import api from "@/lib/axios";
|
||||
import { getApiBaseUrl, formatDate, resolveMediaUrl, isAbsoluteUrl } from "@/lib/media";
|
||||
import { clampTitle } from "@/lib/title";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import AccountSettingsDropdown from "@/components/AccountSettingsDropdown";
|
||||
import VideoPreviewModal from "@/components/VideoPreviewModal";
|
||||
import { useTitleInput } from "@/hooks/useTitleInput";
|
||||
import {
|
||||
ArrowLeft,
|
||||
RotateCcw,
|
||||
LogOut,
|
||||
QrCode,
|
||||
Rocket,
|
||||
Clock,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Eye,
|
||||
} from "lucide-react";
|
||||
|
||||
// SWR fetcher 使用 axios(自动处理 401/403)
|
||||
const fetcher = (url: string) => api.get(url).then((res) => res.data);
|
||||
|
||||
// 动态获取 API 地址:服务端使用 localhost,客户端使用当前域名
|
||||
const API_BASE = typeof window === 'undefined'
|
||||
? 'http://localhost:8006'
|
||||
: '';
|
||||
|
||||
// 格式化日期(避免 Hydration 错误)
|
||||
const formatDate = (timestamp: number) => {
|
||||
const d = new Date(timestamp * 1000);
|
||||
const year = d.getFullYear();
|
||||
const month = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
const hour = String(d.getHours()).padStart(2, '0');
|
||||
const minute = String(d.getMinutes()).padStart(2, '0');
|
||||
return `${year}/${month}/${day} ${hour}:${minute}`;
|
||||
};
|
||||
// 动态获取 API 地址:服务端使用 localhost,客户端使用当前域名
|
||||
const API_BASE = getApiBaseUrl();
|
||||
|
||||
interface Account {
|
||||
platform: string;
|
||||
@@ -38,14 +40,16 @@ interface Video {
|
||||
path: string;
|
||||
}
|
||||
|
||||
export default function PublishPage() {
|
||||
const [accounts, setAccounts] = useState<Account[]>([]);
|
||||
const [videos, setVideos] = useState<Video[]>([]);
|
||||
const [selectedVideo, setSelectedVideo] = useState<string>("");
|
||||
const [selectedPlatforms, setSelectedPlatforms] = useState<string[]>([]);
|
||||
const [title, setTitle] = useState<string>("");
|
||||
const [tags, setTags] = useState<string>("");
|
||||
const [isPublishing, setIsPublishing] = useState(false);
|
||||
export default function PublishPage() {
|
||||
const [accounts, setAccounts] = useState<Account[]>([]);
|
||||
const [videos, setVideos] = useState<Video[]>([]);
|
||||
const [selectedVideo, setSelectedVideo] = useState<string>("");
|
||||
const [videoFilter, setVideoFilter] = useState<string>("");
|
||||
const [previewVideoUrl, setPreviewVideoUrl] = useState<string | null>(null);
|
||||
const [selectedPlatforms, setSelectedPlatforms] = useState<string[]>([]);
|
||||
const [title, setTitle] = useState<string>("");
|
||||
const [tags, setTags] = useState<string>("");
|
||||
const [isPublishing, setIsPublishing] = useState(false);
|
||||
const [publishResults, setPublishResults] = useState<any[]>([]);
|
||||
const [scheduleMode, setScheduleMode] = useState<"now" | "scheduled">("now");
|
||||
const [publishTime, setPublishTime] = useState<string>("");
|
||||
@@ -55,13 +59,20 @@ export default function PublishPage() {
|
||||
|
||||
// 使用全局认证状态
|
||||
const { userId, isLoading: isAuthLoading } = useAuth();
|
||||
// 是否已从 localStorage 恢复完成
|
||||
const [isRestored, setIsRestored] = useState(false);
|
||||
// 是否已从 localStorage 恢复完成
|
||||
const [isRestored, setIsRestored] = useState(false);
|
||||
|
||||
const titleInput = useTitleInput({
|
||||
value: title,
|
||||
onChange: setTitle,
|
||||
});
|
||||
|
||||
// 加载账号和视频列表
|
||||
useEffect(() => {
|
||||
fetchAccounts();
|
||||
fetchVideos();
|
||||
void Promise.allSettled([
|
||||
fetchAccounts(),
|
||||
fetchVideos(),
|
||||
]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -77,17 +88,13 @@ export default function PublishPage() {
|
||||
|
||||
// 从 localStorage 恢复用户输入(等待认证完成后)
|
||||
useEffect(() => {
|
||||
console.log("[Publish] 恢复检查 - isAuthLoading:", isAuthLoading, "userId:", userId);
|
||||
if (isAuthLoading) return;
|
||||
|
||||
console.log("[Publish] 开始从 localStorage 恢复数据,storageKey:", storageKey);
|
||||
// 从 localStorage 恢复用户输入(带用户隔离,未登录用户使用 guest)
|
||||
const savedTitle = localStorage.getItem(`vigent_${storageKey}_publish_title`);
|
||||
const savedTags = localStorage.getItem(`vigent_${storageKey}_publish_tags`);
|
||||
|
||||
console.log("[Publish] localStorage 数据:", { savedTitle, savedTags });
|
||||
|
||||
if (savedTitle) setTitle(savedTitle);
|
||||
if (isAuthLoading) return;
|
||||
|
||||
// 从 localStorage 恢复用户输入(带用户隔离,未登录用户使用 guest)
|
||||
const savedTitle = localStorage.getItem(`vigent_${storageKey}_publish_title`);
|
||||
const savedTags = localStorage.getItem(`vigent_${storageKey}_publish_tags`);
|
||||
|
||||
if (savedTitle) setTitle(clampTitle(savedTitle));
|
||||
if (savedTags) {
|
||||
// 兼容 JSON 数组格式(AI 生成)和字符串格式(手动输入)
|
||||
try {
|
||||
@@ -102,19 +109,26 @@ export default function PublishPage() {
|
||||
}
|
||||
}
|
||||
|
||||
// 恢复完成后才允许保存
|
||||
setIsRestored(true);
|
||||
console.log("[Publish] 恢复完成,isRestored = true");
|
||||
}, [storageKey, isAuthLoading]);
|
||||
// 恢复完成后才允许保存
|
||||
setIsRestored(true);
|
||||
}, [storageKey, isAuthLoading]);
|
||||
|
||||
// 保存用户输入到 localStorage(恢复完成后才保存,未登录用户也可保存)
|
||||
useEffect(() => {
|
||||
if (isRestored) localStorage.setItem(`vigent_${storageKey}_publish_title`, title);
|
||||
}, [title, storageKey, isRestored]);
|
||||
useEffect(() => {
|
||||
if (!isRestored) return;
|
||||
const timeout = setTimeout(() => {
|
||||
localStorage.setItem(`vigent_${storageKey}_publish_title`, title);
|
||||
}, 300);
|
||||
return () => clearTimeout(timeout);
|
||||
}, [title, storageKey, isRestored]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isRestored) localStorage.setItem(`vigent_${storageKey}_publish_tags`, tags);
|
||||
}, [tags, storageKey, isRestored]);
|
||||
useEffect(() => {
|
||||
if (!isRestored) return;
|
||||
const timeout = setTimeout(() => {
|
||||
localStorage.setItem(`vigent_${storageKey}_publish_tags`, tags);
|
||||
}, 300);
|
||||
return () => clearTimeout(timeout);
|
||||
}, [tags, storageKey, isRestored]);
|
||||
|
||||
const fetchAccounts = async () => {
|
||||
try {
|
||||
@@ -262,17 +276,28 @@ export default function PublishPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const platformIcons: Record<string, string> = {
|
||||
douyin: "🎵",
|
||||
xiaohongshu: "📕",
|
||||
weixin: "💬",
|
||||
kuaishou: "⚡",
|
||||
bilibili: "📺",
|
||||
};
|
||||
const platformIcons: Record<string, string> = {
|
||||
douyin: "🎵",
|
||||
xiaohongshu: "📕",
|
||||
weixin: "💬",
|
||||
kuaishou: "⚡",
|
||||
bilibili: "📺",
|
||||
};
|
||||
|
||||
const filteredVideos = useMemo(() => {
|
||||
const query = videoFilter.trim().toLowerCase();
|
||||
if (!query) return videos;
|
||||
return videos.filter((v) => v.name.toLowerCase().includes(query));
|
||||
}, [videos, videoFilter]);
|
||||
|
||||
return (
|
||||
<div className="min-h-dvh">
|
||||
{/* QR码弹窗 */}
|
||||
return (
|
||||
<div className="min-h-dvh">
|
||||
<VideoPreviewModal
|
||||
onClose={() => setPreviewVideoUrl(null)}
|
||||
videoUrl={previewVideoUrl}
|
||||
title="发布视频预览"
|
||||
/>
|
||||
{/* QR码弹窗 */}
|
||||
{qrPlatform && (
|
||||
<div className="fixed inset-0 bg-black/80 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-2xl p-8 max-w-md min-w-[320px]">
|
||||
@@ -312,12 +337,13 @@ export default function PublishPage() {
|
||||
IPAgent
|
||||
</Link>
|
||||
<div className="flex items-center gap-1 sm:gap-4">
|
||||
<Link
|
||||
href="/"
|
||||
className="px-2 sm:px-4 py-1 sm:py-2 text-sm sm:text-base bg-white/10 hover:bg-white/20 text-white rounded-lg transition-colors"
|
||||
>
|
||||
返回创作
|
||||
</Link>
|
||||
<Link
|
||||
href="/"
|
||||
className="px-2 sm:px-4 py-1 sm:py-2 text-sm sm:text-base bg-white/10 hover:bg-white/20 text-white rounded-lg transition-colors flex items-center gap-1"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
返回创作
|
||||
</Link>
|
||||
<span className="px-2 sm:px-4 py-1 sm:py-2 text-sm sm:text-base bg-gradient-to-r from-purple-600 to-pink-600 text-white rounded-lg font-semibold">
|
||||
发布管理
|
||||
</span>
|
||||
@@ -362,26 +388,29 @@ export default function PublishPage() {
|
||||
<div className="flex gap-2">
|
||||
{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"
|
||||
>
|
||||
↻ 重新登录
|
||||
</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"
|
||||
>
|
||||
注销
|
||||
</button>
|
||||
<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"
|
||||
>
|
||||
<RotateCcw className="h-3.5 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"
|
||||
>
|
||||
<LogOut className="h-3.5 w-3.5" />
|
||||
注销
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => handleLogin(account.platform)}
|
||||
className="px-3 py-1 bg-purple-600 hover:bg-purple-700 text-white text-sm rounded-lg transition-colors"
|
||||
>
|
||||
🔐 扫码登录
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleLogin(account.platform)}
|
||||
className="px-3 py-1 bg-purple-600 hover:bg-purple-700 text-white text-sm rounded-lg transition-colors flex items-center gap-1"
|
||||
>
|
||||
<QrCode className="h-3.5 w-3.5" />
|
||||
扫码登录
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -392,33 +421,92 @@ export default 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>
|
||||
|
||||
{videos.length === 0 ? (
|
||||
<p className="text-gray-400">
|
||||
暂无已生成的视频,请先
|
||||
<Link href="/" className="text-purple-400 hover:underline">
|
||||
生成视频
|
||||
</Link>
|
||||
</p>
|
||||
) : (
|
||||
<select
|
||||
value={selectedVideo}
|
||||
onChange={(e) => setSelectedVideo(e.target.value)}
|
||||
className="w-full p-3 bg-black/30 border border-white/10 rounded-xl text-white custom-select cursor-pointer hover:border-purple-500/50 transition-colors"
|
||||
>
|
||||
{videos.map((v) => (
|
||||
<option key={v.path} value={v.path}>
|
||||
{v.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</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">
|
||||
🎥 选择要发布的作品
|
||||
</h2>
|
||||
|
||||
{videos.length === 0 ? (
|
||||
<p className="text-gray-400">
|
||||
暂无已生成的视频,请先
|
||||
<Link href="/" className="text-purple-400 hover:underline">
|
||||
生成视频
|
||||
</Link>
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-500" />
|
||||
<input
|
||||
value={videoFilter}
|
||||
onChange={(e) => setVideoFilter(e.target.value)}
|
||||
placeholder="搜索视频..."
|
||||
className="w-full pl-9 pr-3 py-2 bg-black/30 border border-white/10 rounded-lg text-white text-sm placeholder-gray-500 focus:outline-none focus:border-purple-500 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={fetchVideos}
|
||||
className="px-2 py-2 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>
|
||||
|
||||
{filteredVideos.length === 0 ? (
|
||||
<div className="text-center py-4 text-gray-500 text-sm">
|
||||
没有匹配的视频
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="space-y-2 max-h-64 overflow-y-auto hide-scrollbar"
|
||||
style={{ contentVisibility: 'auto' }}
|
||||
>
|
||||
{filteredVideos.map((v) => (
|
||||
<div
|
||||
key={v.path}
|
||||
className={`p-3 rounded-lg border transition-all flex items-center justify-between group ${selectedVideo === v.path
|
||||
? "border-purple-500 bg-purple-500/20"
|
||||
: "border-white/10 bg-white/5 hover:border-white/30"
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
onClick={() => setSelectedVideo(v.path)}
|
||||
className="flex-1 text-left"
|
||||
>
|
||||
<div className="text-white text-sm truncate">
|
||||
{v.name}
|
||||
</div>
|
||||
</button>
|
||||
<div className="flex items-center gap-2 pl-2">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const previewPath = isAbsoluteUrl(v.path)
|
||||
? v.path
|
||||
: v.path.startsWith('/')
|
||||
? v.path
|
||||
: `/${v.path}`;
|
||||
setPreviewVideoUrl(resolveMediaUrl(previewPath) || previewPath);
|
||||
}}
|
||||
className="p-1 text-gray-500 hover:text-purple-400 transition-colors"
|
||||
title="预览"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</button>
|
||||
{selectedVideo === v.path && (
|
||||
<span className="text-xs text-purple-300">已选</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 填写信息 */}
|
||||
<div className="bg-white/5 rounded-2xl p-6 border border-white/10 backdrop-blur-sm">
|
||||
@@ -429,13 +517,15 @@ export default function PublishPage() {
|
||||
<label className="block text-gray-400 text-sm mb-2">
|
||||
标题
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="输入视频标题..."
|
||||
className="w-full p-3 bg-black/30 border border-white/10 rounded-xl text-white placeholder-gray-500"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => titleInput.handleChange(e.target.value)}
|
||||
onCompositionStart={titleInput.handleCompositionStart}
|
||||
onCompositionEnd={(e) => titleInput.handleCompositionEnd(e.currentTarget.value)}
|
||||
placeholder="输入视频标题..."
|
||||
className="w-full p-3 bg-black/30 border border-white/10 rounded-xl text-white placeholder-gray-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-gray-400 text-sm mb-2">
|
||||
@@ -487,32 +577,40 @@ export default function PublishPage() {
|
||||
<div className="space-y-3">
|
||||
<div className="flex gap-3">
|
||||
{/* 立即发布 - 占 3/4 */}
|
||||
<button
|
||||
onClick={() => {
|
||||
setScheduleMode("now");
|
||||
handlePublish();
|
||||
}}
|
||||
disabled={isPublishing || selectedPlatforms.length === 0}
|
||||
className={`flex-[3] py-4 rounded-xl font-bold text-lg transition-all ${isPublishing || selectedPlatforms.length === 0
|
||||
? "bg-gray-600 cursor-not-allowed text-gray-400"
|
||||
: "bg-gradient-to-r from-green-600 to-teal-600 hover:from-green-700 hover:to-teal-700 text-white"
|
||||
}`}
|
||||
>
|
||||
{isPublishing && scheduleMode === "now" ? "发布中..." : "🚀 立即发布"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setScheduleMode("now");
|
||||
handlePublish();
|
||||
}}
|
||||
disabled={isPublishing || selectedPlatforms.length === 0}
|
||||
className={`flex-[3] py-4 rounded-xl font-bold text-lg transition-all flex items-center justify-center gap-2 ${isPublishing || selectedPlatforms.length === 0
|
||||
? "bg-gray-600 cursor-not-allowed text-gray-400"
|
||||
: "bg-gradient-to-r from-green-600 to-teal-600 hover:from-green-700 hover:to-teal-700 text-white"
|
||||
}`}
|
||||
>
|
||||
{isPublishing && scheduleMode === "now" ? (
|
||||
"发布中..."
|
||||
) : (
|
||||
<>
|
||||
<Rocket className="h-5 w-5" />
|
||||
立即发布
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
{/* 定时发布 - 占 1/4 */}
|
||||
<button
|
||||
onClick={() => setScheduleMode(scheduleMode === "scheduled" ? "now" : "scheduled")}
|
||||
disabled={isPublishing || selectedPlatforms.length === 0}
|
||||
className={`flex-1 py-4 rounded-xl font-bold text-base transition-all ${isPublishing || selectedPlatforms.length === 0
|
||||
? "bg-gray-600 cursor-not-allowed text-gray-400"
|
||||
: scheduleMode === "scheduled"
|
||||
? "bg-purple-600 text-white"
|
||||
: "bg-white/10 hover:bg-white/20 text-white"
|
||||
}`}
|
||||
>
|
||||
⏰ 定时
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setScheduleMode(scheduleMode === "scheduled" ? "now" : "scheduled")}
|
||||
disabled={isPublishing || selectedPlatforms.length === 0}
|
||||
className={`flex-1 py-4 rounded-xl font-bold text-base transition-all flex items-center justify-center gap-2 ${isPublishing || selectedPlatforms.length === 0
|
||||
? "bg-gray-600 cursor-not-allowed text-gray-400"
|
||||
: scheduleMode === "scheduled"
|
||||
? "bg-purple-600 text-white"
|
||||
: "bg-white/10 hover:bg-white/20 text-white"
|
||||
}`}
|
||||
>
|
||||
<Clock className="h-5 w-5" />
|
||||
定时
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 定时发布时间选择器 */}
|
||||
@@ -569,5 +667,5 @@ export default function PublishPage() {
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import api from "@/lib/axios";
|
||||
|
||||
interface ScriptExtractionModalProps {
|
||||
|
||||
@@ -1,50 +1,71 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { X, Video } from "lucide-react";
|
||||
|
||||
interface VideoPreviewModalProps {
|
||||
videoUrl: string | null;
|
||||
onClose: () => void;
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
}
|
||||
|
||||
export default function VideoPreviewModal({ videoUrl, onClose }: VideoPreviewModalProps) {
|
||||
useEffect(() => {
|
||||
// 按 ESC 关闭
|
||||
const handleEsc = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
if (videoUrl) {
|
||||
document.addEventListener('keydown', handleEsc);
|
||||
// 禁止背景滚动
|
||||
document.body.style.overflow = 'hidden';
|
||||
}
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleEsc);
|
||||
document.body.style.overflow = 'unset';
|
||||
};
|
||||
}, [videoUrl, onClose]);
|
||||
export default function VideoPreviewModal({
|
||||
videoUrl,
|
||||
onClose,
|
||||
title = "视频预览",
|
||||
subtitle = "ESC 关闭 · 点击空白关闭",
|
||||
}: VideoPreviewModalProps) {
|
||||
useEffect(() => {
|
||||
if (!videoUrl) return;
|
||||
// 按 ESC 关闭
|
||||
const handleEsc = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
const prevOverflow = document.body.style.overflow;
|
||||
document.addEventListener('keydown', handleEsc);
|
||||
// 禁止背景滚动
|
||||
document.body.style.overflow = 'hidden';
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleEsc);
|
||||
document.body.style.overflow = prevOverflow;
|
||||
};
|
||||
}, [videoUrl, onClose]);
|
||||
|
||||
if (!videoUrl) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[200] flex items-center justify-center bg-black/80 backdrop-blur-sm p-4 animate-in fade-in duration-200">
|
||||
<div className="relative w-full max-w-4xl bg-gray-900 border border-white/10 rounded-2xl shadow-2xl overflow-hidden flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-2 border-b border-white/10 bg-white/5">
|
||||
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
|
||||
🎥 视频预览
|
||||
</h3>
|
||||
<div
|
||||
className="fixed inset-0 z-[200] flex items-center justify-center bg-black/80 backdrop-blur-sm p-4 animate-in fade-in duration-200"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="relative w-full max-w-4xl bg-gray-900 border border-white/10 rounded-2xl shadow-2xl overflow-hidden flex flex-col"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between px-6 py-3 border-b border-white/10 bg-gradient-to-r from-white/5 via-white/0 to-white/5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-9 w-9 rounded-lg bg-white/10 flex items-center justify-center text-white">
|
||||
<Video className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white">
|
||||
{title}
|
||||
</h3>
|
||||
<p className="text-xs text-gray-400">
|
||||
{subtitle}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 text-gray-400 hover:text-white hover:bg-white/10 rounded-lg transition-colors"
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Video Player */}
|
||||
<div className="bg-black flex items-center justify-center min-h-[50vh] max-h-[80vh]">
|
||||
<video
|
||||
src={videoUrl}
|
||||
@@ -53,12 +74,7 @@ export default function VideoPreviewModal({ videoUrl, onClose }: VideoPreviewMod
|
||||
className="w-full h-full max-h-[80vh] object-contain"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
{/* Click outside to close */}
|
||||
<div className="absolute inset-0 -z-10" onClick={onClose}></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
137
frontend/src/components/home/BgmPanel.tsx
Normal file
137
frontend/src/components/home/BgmPanel.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import type { RefObject, MouseEvent } from "react";
|
||||
import { RefreshCw, Play, Pause } from "lucide-react";
|
||||
|
||||
interface BgmItem {
|
||||
id: string;
|
||||
name: string;
|
||||
ext?: string;
|
||||
}
|
||||
|
||||
interface BgmPanelProps {
|
||||
bgmList: BgmItem[];
|
||||
bgmLoading: boolean;
|
||||
bgmError: string;
|
||||
enableBgm: boolean;
|
||||
onToggleEnable: (value: boolean) => void;
|
||||
onRefresh: () => void;
|
||||
selectedBgmId: string;
|
||||
onSelectBgm: (id: string) => void;
|
||||
playingBgmId: string | null;
|
||||
onTogglePreview: (bgm: BgmItem, event: MouseEvent) => void;
|
||||
bgmVolume: number;
|
||||
onVolumeChange: (value: number) => void;
|
||||
bgmListContainerRef: RefObject<HTMLDivElement | null>;
|
||||
registerBgmItemRef: (id: string, element: HTMLDivElement | null) => void;
|
||||
}
|
||||
|
||||
export function BgmPanel({
|
||||
bgmList,
|
||||
bgmLoading,
|
||||
bgmError,
|
||||
enableBgm,
|
||||
onToggleEnable,
|
||||
onRefresh,
|
||||
selectedBgmId,
|
||||
onSelectBgm,
|
||||
playingBgmId,
|
||||
onTogglePreview,
|
||||
bgmVolume,
|
||||
onVolumeChange,
|
||||
bgmListContainerRef,
|
||||
registerBgmItemRef,
|
||||
}: BgmPanelProps) {
|
||||
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>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={onRefresh}
|
||||
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>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enableBgm}
|
||||
onChange={(e) => onToggleEnable(e.target.checked)}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-11 h-6 bg-gray-600 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-purple-600"></div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{bgmLoading ? (
|
||||
<div className="text-center py-4 text-gray-400 text-sm">正在加载背景音乐...</div>
|
||||
) : bgmError ? (
|
||||
<div className="text-center py-4 text-red-300 text-sm">
|
||||
加载失败:{bgmError}
|
||||
<button
|
||||
onClick={onRefresh}
|
||||
className="ml-2 px-2 py-1 text-xs bg-white/10 hover:bg-white/20 rounded text-gray-300"
|
||||
>
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
) : bgmList.length === 0 ? (
|
||||
<div className="text-center py-4 text-gray-500 text-sm">暂无背景音乐,请先导入素材</div>
|
||||
) : (
|
||||
<div
|
||||
ref={bgmListContainerRef}
|
||||
className={`space-y-2 max-h-64 overflow-y-auto hide-scrollbar ${enableBgm ? '' : 'opacity-70'}`}
|
||||
>
|
||||
{bgmList.map((bgm) => (
|
||||
<div
|
||||
key={bgm.id}
|
||||
ref={(el) => registerBgmItemRef(bgm.id, el)}
|
||||
className={`p-3 rounded-lg border transition-all flex items-center justify-between group ${selectedBgmId === bgm.id
|
||||
? "border-purple-500 bg-purple-500/20"
|
||||
: "border-white/10 bg-white/5 hover:border-white/30"
|
||||
}`}
|
||||
>
|
||||
<button onClick={() => onSelectBgm(bgm.id)} className="flex-1 text-left">
|
||||
<div className="text-white text-sm truncate">{bgm.name}</div>
|
||||
<div className="text-xs text-gray-400">.{bgm.ext || 'audio'}</div>
|
||||
</button>
|
||||
<div className="flex items-center gap-2 pl-2">
|
||||
<button
|
||||
onClick={(e) => onTogglePreview(bgm, e)}
|
||||
className="p-1 text-gray-500 hover:text-purple-400 transition-colors"
|
||||
title="试听"
|
||||
>
|
||||
{playingBgmId === bgm.id ? (
|
||||
<Pause className="h-4 w-4" />
|
||||
) : (
|
||||
<Play className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
{selectedBgmId === bgm.id && (
|
||||
<span className="text-xs text-purple-300">已选</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{enableBgm && (
|
||||
<div className="mt-4">
|
||||
<label className="text-sm text-gray-300 mb-2 block">音量</label>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.05"
|
||||
value={bgmVolume}
|
||||
onChange={(e) => onVolumeChange(parseFloat(e.target.value))}
|
||||
className="w-full accent-purple-500"
|
||||
/>
|
||||
<div className="text-xs text-gray-400 mt-1">当前: {Math.round(bgmVolume * 100)}%</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
53
frontend/src/components/home/GenerateActionBar.tsx
Normal file
53
frontend/src/components/home/GenerateActionBar.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import { Rocket } from "lucide-react";
|
||||
|
||||
interface GenerateActionBarProps {
|
||||
isGenerating: boolean;
|
||||
progress: number;
|
||||
disabled: boolean;
|
||||
onGenerate: () => void;
|
||||
}
|
||||
|
||||
export function GenerateActionBar({
|
||||
isGenerating,
|
||||
progress,
|
||||
disabled,
|
||||
onGenerate,
|
||||
}: GenerateActionBarProps) {
|
||||
return (
|
||||
<button
|
||||
onClick={onGenerate}
|
||||
disabled={disabled}
|
||||
className={`w-full py-4 rounded-xl font-bold text-lg transition-all ${disabled
|
||||
? "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 shadow-lg hover:shadow-purple-500/25"
|
||||
}`}
|
||||
>
|
||||
{isGenerating ? (
|
||||
<span className="flex items-center justify-center gap-3">
|
||||
<svg className="animate-spin h-5 w-5" viewBox="0 0 24 24">
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
fill="none"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
||||
/>
|
||||
</svg>
|
||||
生成中... {progress}%
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<Rocket className="h-5 w-5" />
|
||||
生成视频
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
80
frontend/src/components/home/HistoryList.tsx
Normal file
80
frontend/src/components/home/HistoryList.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import { RefreshCw, Trash2 } from "lucide-react";
|
||||
|
||||
interface GeneratedVideo {
|
||||
id: string;
|
||||
name: string;
|
||||
path: string;
|
||||
size_mb: number;
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
interface HistoryListProps {
|
||||
generatedVideos: GeneratedVideo[];
|
||||
selectedVideoId: string | null;
|
||||
onSelectVideo: (video: GeneratedVideo) => void;
|
||||
onDeleteVideo: (id: string) => void;
|
||||
onRefresh: () => void;
|
||||
registerVideoRef: (id: string, element: HTMLDivElement | null) => void;
|
||||
formatDate: (timestamp: number) => string;
|
||||
}
|
||||
|
||||
export function HistoryList({
|
||||
generatedVideos,
|
||||
selectedVideoId,
|
||||
onSelectVideo,
|
||||
onDeleteVideo,
|
||||
onRefresh,
|
||||
registerVideoRef,
|
||||
formatDate,
|
||||
}: 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>
|
||||
{generatedVideos.length === 0 ? (
|
||||
<div className="text-center py-4 text-gray-500">
|
||||
<p>暂无生成的作品</p>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="space-y-2 max-h-64 overflow-y-auto hide-scrollbar"
|
||||
style={{ contentVisibility: 'auto' }}
|
||||
>
|
||||
{generatedVideos.map((v) => (
|
||||
<div
|
||||
key={v.id}
|
||||
ref={(el) => registerVideoRef(v.id, el)}
|
||||
className={`p-3 rounded-lg border transition-all flex items-center justify-between group ${selectedVideoId === v.id
|
||||
? "border-purple-500 bg-purple-500/20"
|
||||
: "border-white/10 bg-white/5 hover:border-white/30"
|
||||
}`}
|
||||
>
|
||||
<button onClick={() => onSelectVideo(v)} className="flex-1 text-left">
|
||||
<div className="text-white text-sm truncate">{formatDate(v.created_at)}</div>
|
||||
<div className="text-gray-400 text-xs">{v.size_mb.toFixed(1)} MB</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDeleteVideo(v.id);
|
||||
}}
|
||||
className="p-1 text-gray-500 hover:text-red-400 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
title="删除视频"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
30
frontend/src/components/home/HomeHeader.tsx
Normal file
30
frontend/src/components/home/HomeHeader.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import Link from "next/link";
|
||||
import AccountSettingsDropdown from "@/components/AccountSettingsDropdown";
|
||||
|
||||
export function HomeHeader() {
|
||||
return (
|
||||
<header className="border-b border-white/10 bg-black/20 backdrop-blur-sm relative z-[100]">
|
||||
<div className="max-w-6xl mx-auto px-4 sm:px-6 py-3 sm:py-4 flex items-center justify-between">
|
||||
<Link
|
||||
href="/"
|
||||
className="text-xl sm:text-2xl font-bold text-white flex items-center gap-2 sm:gap-3 hover:opacity-80 transition-opacity"
|
||||
>
|
||||
<span className="text-3xl sm:text-4xl">🎬</span>
|
||||
IPAgent
|
||||
</Link>
|
||||
<div className="flex items-center gap-1 sm:gap-4">
|
||||
<span className="px-2 sm:px-4 py-1 sm:py-2 text-sm sm:text-base bg-gradient-to-r from-purple-600 to-pink-600 text-white rounded-lg font-semibold">
|
||||
视频生成
|
||||
</span>
|
||||
<Link
|
||||
href="/publish"
|
||||
className="px-2 sm:px-4 py-1 sm:py-2 text-sm sm:text-base bg-white/10 hover:bg-white/20 text-white rounded-lg transition-colors"
|
||||
>
|
||||
发布管理
|
||||
</Link>
|
||||
<AccountSettingsDropdown />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
168
frontend/src/components/home/MaterialSelector.tsx
Normal file
168
frontend/src/components/home/MaterialSelector.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
import type { ChangeEvent } from "react";
|
||||
import { Upload, RefreshCw, Eye, Trash2, X } from "lucide-react";
|
||||
|
||||
interface Material {
|
||||
id: string;
|
||||
name: string;
|
||||
scene: string;
|
||||
size_mb: number;
|
||||
path: string;
|
||||
}
|
||||
|
||||
interface MaterialSelectorProps {
|
||||
materials: Material[];
|
||||
selectedMaterial: string;
|
||||
isUploading: boolean;
|
||||
uploadProgress: number;
|
||||
uploadError: string | null;
|
||||
fetchError: string | null;
|
||||
apiBase: string;
|
||||
onUploadChange: (event: ChangeEvent<HTMLInputElement>) => void;
|
||||
onRefresh: () => void;
|
||||
onSelectMaterial: (id: string) => void;
|
||||
onPreviewMaterial: (path: string) => void;
|
||||
onDeleteMaterial: (id: string) => void;
|
||||
onClearUploadError: () => void;
|
||||
registerMaterialRef: (id: string, element: HTMLDivElement | null) => void;
|
||||
}
|
||||
|
||||
export function MaterialSelector({
|
||||
materials,
|
||||
selectedMaterial,
|
||||
isUploading,
|
||||
uploadProgress,
|
||||
uploadError,
|
||||
fetchError,
|
||||
apiBase,
|
||||
onUploadChange,
|
||||
onRefresh,
|
||||
onSelectMaterial,
|
||||
onPreviewMaterial,
|
||||
onDeleteMaterial,
|
||||
onClearUploadError,
|
||||
registerMaterialRef,
|
||||
}: MaterialSelectorProps) {
|
||||
return (
|
||||
<div className="bg-white/5 rounded-2xl p-4 sm:p-6 border border-white/10 backdrop-blur-sm">
|
||||
<div className="flex justify-between items-center gap-2 mb-4">
|
||||
<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">
|
||||
(上传自拍视频)
|
||||
</span>
|
||||
</h2>
|
||||
<div className="flex gap-1.5">
|
||||
<input
|
||||
type="file"
|
||||
id="video-upload"
|
||||
accept=".mp4,.mov,.avi"
|
||||
onChange={onUploadChange}
|
||||
className="hidden"
|
||||
/>
|
||||
<label
|
||||
htmlFor="video-upload"
|
||||
className={`px-2 py-1 text-xs rounded cursor-pointer transition-all whitespace-nowrap flex items-center gap-1 ${isUploading
|
||||
? "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"
|
||||
}`}
|
||||
>
|
||||
<Upload className="h-3.5 w-3.5" />
|
||||
上传
|
||||
</label>
|
||||
<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>
|
||||
|
||||
{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>{uploadProgress}%</span>
|
||||
</div>
|
||||
<div className="h-2 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: `${uploadProgress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{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>
|
||||
<button onClick={onClearUploadError} className="text-red-300 hover:text-white">
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{fetchError ? (
|
||||
<div className="p-4 bg-red-500/20 text-red-200 rounded-xl text-sm mb-4">
|
||||
获取素材失败: {fetchError}
|
||||
<br />
|
||||
API: {apiBase}/api/materials/
|
||||
</div>
|
||||
) : materials.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-400">
|
||||
<div className="text-5xl mb-4">📁</div>
|
||||
<p>暂无视频素材</p>
|
||||
<p className="text-sm mt-2">
|
||||
点击上方「📤 上传视频」按钮添加视频素材
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="space-y-2 max-h-64 overflow-y-auto hide-scrollbar"
|
||||
style={{ contentVisibility: 'auto' }}
|
||||
>
|
||||
{materials.map((m) => (
|
||||
<div
|
||||
key={m.id}
|
||||
ref={(el) => registerMaterialRef(m.id, el)}
|
||||
className={`p-3 rounded-lg border transition-all flex items-center justify-between group ${selectedMaterial === m.id
|
||||
? "border-purple-500 bg-purple-500/20"
|
||||
: "border-white/10 bg-white/5 hover:border-white/30"
|
||||
}`}
|
||||
>
|
||||
<button onClick={() => onSelectMaterial(m.id)} className="flex-1 text-left">
|
||||
<div className="text-white text-sm truncate">{m.scene || m.name}</div>
|
||||
<div className="text-gray-400 text-xs">{m.size_mb.toFixed(1)} MB</div>
|
||||
</button>
|
||||
<div className="flex items-center gap-2 pl-2">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (m.path) {
|
||||
onPreviewMaterial(m.path);
|
||||
}
|
||||
}}
|
||||
className="p-1 text-gray-500 hover:text-white opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
title="预览视频"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDeleteMaterial(m.id);
|
||||
}}
|
||||
className="p-1 text-gray-500 hover:text-red-400 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
title="删除素材"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
74
frontend/src/components/home/PreviewPanel.tsx
Normal file
74
frontend/src/components/home/PreviewPanel.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import Link from "next/link";
|
||||
import { Download, Send } from "lucide-react";
|
||||
|
||||
interface Task {
|
||||
task_id: string;
|
||||
status: string;
|
||||
progress: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface PreviewPanelProps {
|
||||
currentTask: Task | null;
|
||||
isGenerating: boolean;
|
||||
generatedVideo: string | null;
|
||||
}
|
||||
|
||||
export function PreviewPanel({
|
||||
currentTask,
|
||||
isGenerating,
|
||||
generatedVideo,
|
||||
}: PreviewPanelProps) {
|
||||
return (
|
||||
<>
|
||||
{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="space-y-3">
|
||||
<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}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-gray-300">正在AI生成中...</p>
|
||||
</div>
|
||||
</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="aspect-video bg-black/50 rounded-xl overflow-hidden flex items-center justify-center">
|
||||
{generatedVideo ? (
|
||||
<video src={generatedVideo} controls className="w-full h-full object-contain" />
|
||||
) : (
|
||||
<div className="text-gray-500 text-center">
|
||||
<div className="text-5xl mb-4">📹</div>
|
||||
<p>生成的作品将在这里预览</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{generatedVideo && (
|
||||
<>
|
||||
<a
|
||||
href={generatedVideo}
|
||||
download
|
||||
className="mt-4 w-full py-3 rounded-xl bg-green-600 hover:bg-green-700 text-white font-medium flex items-center justify-center gap-2 transition-colors"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
下载视频
|
||||
</a>
|
||||
<Link
|
||||
href="/publish"
|
||||
className="mt-3 w-full py-3 rounded-xl bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-700 hover:to-pink-700 text-white font-medium flex items-center justify-center gap-2 transition-colors"
|
||||
>
|
||||
<Send className="h-4 w-4" />
|
||||
发布到社交平台
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
277
frontend/src/components/home/RefAudioPanel.tsx
Normal file
277
frontend/src/components/home/RefAudioPanel.tsx
Normal file
@@ -0,0 +1,277 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { MouseEvent } from "react";
|
||||
import { Upload, RefreshCw, Play, Pause, Pencil, Trash2, Check, X, Mic, Square } from "lucide-react";
|
||||
|
||||
interface RefAudio {
|
||||
id: string;
|
||||
name: string;
|
||||
path: string;
|
||||
ref_text: string;
|
||||
duration_sec: number;
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
interface RefAudioPanelProps {
|
||||
refAudios: RefAudio[];
|
||||
selectedRefAudio: RefAudio | null;
|
||||
onSelectRefAudio: (audio: RefAudio) => void;
|
||||
isUploadingRef: boolean;
|
||||
uploadRefError: string | null;
|
||||
onClearUploadRefError: () => void;
|
||||
onUploadRefAudio: (file: File) => void;
|
||||
onFetchRefAudios: () => void;
|
||||
playingAudioId: string | null;
|
||||
onTogglePlayPreview: (audio: RefAudio, event: MouseEvent) => void;
|
||||
editingAudioId: string | null;
|
||||
editName: string;
|
||||
onEditNameChange: (value: string) => void;
|
||||
onStartEditing: (audio: RefAudio, event: MouseEvent) => void;
|
||||
onSaveEditing: (id: string, event: MouseEvent) => void;
|
||||
onCancelEditing: (event: MouseEvent) => void;
|
||||
onDeleteRefAudio: (id: string) => void;
|
||||
recordedBlob: Blob | null;
|
||||
isRecording: boolean;
|
||||
recordingTime: number;
|
||||
onStartRecording: () => void;
|
||||
onStopRecording: () => void;
|
||||
onUseRecording: () => void;
|
||||
formatRecordingTime: (seconds: number) => string;
|
||||
fixedRefText: string;
|
||||
}
|
||||
|
||||
export function RefAudioPanel({
|
||||
refAudios,
|
||||
selectedRefAudio,
|
||||
onSelectRefAudio,
|
||||
isUploadingRef,
|
||||
uploadRefError,
|
||||
onClearUploadRefError,
|
||||
onUploadRefAudio,
|
||||
onFetchRefAudios,
|
||||
playingAudioId,
|
||||
onTogglePlayPreview,
|
||||
editingAudioId,
|
||||
editName,
|
||||
onEditNameChange,
|
||||
onStartEditing,
|
||||
onSaveEditing,
|
||||
onCancelEditing,
|
||||
onDeleteRefAudio,
|
||||
recordedBlob,
|
||||
isRecording,
|
||||
recordingTime,
|
||||
onStartRecording,
|
||||
onStopRecording,
|
||||
onUseRecording,
|
||||
formatRecordingTime,
|
||||
fixedRefText,
|
||||
}: RefAudioPanelProps) {
|
||||
const [recordedUrl, setRecordedUrl] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!recordedBlob) {
|
||||
setRecordedUrl(null);
|
||||
return;
|
||||
}
|
||||
const url = URL.createObjectURL(recordedBlob);
|
||||
setRecordedUrl(url);
|
||||
return () => {
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
}, [recordedBlob]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<span className="text-sm text-gray-300">📁 我的参考音频</span>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="file"
|
||||
id="ref-audio-upload"
|
||||
accept=".wav,.mp3,.m4a,.webm,.ogg,.flac,.aac"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
onUploadRefAudio(file);
|
||||
}
|
||||
e.target.value = '';
|
||||
}}
|
||||
className="hidden"
|
||||
/>
|
||||
<label
|
||||
htmlFor="ref-audio-upload"
|
||||
className={`px-2 py-1 text-xs rounded cursor-pointer transition-all flex items-center gap-1 ${isUploadingRef
|
||||
? "bg-gray-600 cursor-not-allowed text-gray-400"
|
||||
: "bg-purple-600 hover:bg-purple-700 text-white"
|
||||
}`}
|
||||
>
|
||||
<Upload className="h-3.5 w-3.5" />
|
||||
上传
|
||||
</label>
|
||||
<button
|
||||
onClick={onFetchRefAudios}
|
||||
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>
|
||||
</div>
|
||||
|
||||
{isUploadingRef && (
|
||||
<div className="mb-2 p-2 bg-purple-500/10 rounded text-sm text-purple-300">
|
||||
⏳ 上传中...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{uploadRefError && (
|
||||
<div className="mb-2 p-2 bg-red-500/20 text-red-200 rounded text-xs flex justify-between">
|
||||
<span>❌ {uploadRefError}</span>
|
||||
<button onClick={onClearUploadRefError} className="text-red-300 hover:text-white">
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{refAudios.length === 0 ? (
|
||||
<div className="text-center py-4 text-gray-500 text-sm">
|
||||
暂无参考音频,请上传或录制
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-2" style={{ contentVisibility: 'auto' }}>
|
||||
{refAudios.map((audio) => (
|
||||
<div
|
||||
key={audio.id}
|
||||
className={`p-2 rounded-lg border transition-all relative group cursor-pointer ${selectedRefAudio?.id === audio.id
|
||||
? "border-purple-500 bg-purple-500/20"
|
||||
: "border-white/10 bg-white/5 hover:border-white/30"
|
||||
}`}
|
||||
onClick={() => {
|
||||
if (editingAudioId !== audio.id) {
|
||||
onSelectRefAudio(audio);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{editingAudioId === audio.id ? (
|
||||
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
|
||||
<input
|
||||
type="text"
|
||||
value={editName}
|
||||
onChange={(e) => onEditNameChange(e.target.value)}
|
||||
className="w-full bg-black/50 text-white text-xs px-1 py-0.5 rounded border border-purple-500 focus:outline-none"
|
||||
autoFocus
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') onSaveEditing(audio.id, e as any);
|
||||
if (e.key === 'Escape') onCancelEditing(e as any);
|
||||
}}
|
||||
/>
|
||||
<button onClick={(e) => onSaveEditing(audio.id, e)} className="text-green-400 hover:text-green-300 text-xs">
|
||||
<Check className="h-3 w-3" />
|
||||
</button>
|
||||
<button onClick={(e) => onCancelEditing(e)} className="text-gray-400 hover:text-gray-300 text-xs">
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex justify-between items-start mb-1">
|
||||
<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">
|
||||
<button
|
||||
onClick={(e) => onTogglePlayPreview(audio, e)}
|
||||
className="text-gray-400 hover:text-purple-400 text-xs"
|
||||
title="试听"
|
||||
>
|
||||
{playingAudioId === audio.id ? (
|
||||
<Pause className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Play className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => onStartEditing(audio, e)}
|
||||
className="text-gray-400 hover:text-blue-400 text-xs"
|
||||
title="重命名"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDeleteRefAudio(audio.id);
|
||||
}}
|
||||
className="text-gray-400 hover:text-red-400 text-xs"
|
||||
title="删除"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-gray-400 text-xs">{audio.duration_sec.toFixed(1)}s</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-white/10 pt-4">
|
||||
<span className="text-sm text-gray-300 mb-2 block">🎤 或在线录音</span>
|
||||
<div className="flex gap-2 items-center">
|
||||
{!isRecording ? (
|
||||
<button
|
||||
onClick={onStartRecording}
|
||||
className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg text-sm font-medium transition-colors flex items-center gap-2"
|
||||
>
|
||||
<Mic className="h-4 w-4" />
|
||||
开始录音
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={onStopRecording}
|
||||
className="px-4 py-2 bg-gray-600 hover:bg-gray-700 text-white rounded-lg text-sm font-medium transition-colors flex items-center gap-2"
|
||||
>
|
||||
<Square className="h-4 w-4" />
|
||||
停止
|
||||
</button>
|
||||
)}
|
||||
{isRecording && (
|
||||
<span className="text-red-400 text-sm animate-pulse">
|
||||
🔴 录音中 {formatRecordingTime(recordingTime)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{recordedBlob && !isRecording && (
|
||||
<div className="mt-3 p-3 bg-green-500/10 border border-green-500/30 rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-green-300 text-sm">✅ 录音完成 ({formatRecordingTime(recordingTime)})</span>
|
||||
<audio src={recordedUrl || ''} controls className="h-8" />
|
||||
</div>
|
||||
<button
|
||||
onClick={onUseRecording}
|
||||
disabled={isUploadingRef}
|
||||
className="px-3 py-1 bg-green-600 hover:bg-green-700 text-white rounded text-sm disabled:bg-gray-600"
|
||||
>
|
||||
使用此录音
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-white/10 pt-4">
|
||||
<label className="text-sm text-gray-300 mb-2 block">📝 录音/上传时请朗读以下内容:</label>
|
||||
<div className="w-full bg-black/30 border border-white/10 rounded-lg p-3 text-white text-sm">
|
||||
{fixedRefText}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
请清晰朗读上述内容完成录音,系统将以此为参考克隆您的声音
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
66
frontend/src/components/home/ScriptEditor.tsx
Normal file
66
frontend/src/components/home/ScriptEditor.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import { FileText, Loader2, Sparkles } from "lucide-react";
|
||||
|
||||
interface ScriptEditorProps {
|
||||
text: string;
|
||||
onChangeText: (value: string) => void;
|
||||
onOpenExtractModal: () => void;
|
||||
onGenerateMeta: () => void;
|
||||
isGeneratingMeta: boolean;
|
||||
}
|
||||
|
||||
export function ScriptEditor({
|
||||
text,
|
||||
onChangeText,
|
||||
onOpenExtractModal,
|
||||
onGenerateMeta,
|
||||
isGeneratingMeta,
|
||||
}: ScriptEditorProps) {
|
||||
return (
|
||||
<div className="bg-white/5 rounded-2xl p-6 border border-white/10 backdrop-blur-sm">
|
||||
<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">
|
||||
✍️ 文案提取与编辑
|
||||
</h2>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={onOpenExtractModal}
|
||||
className="px-2 py-1 text-xs rounded transition-all whitespace-nowrap bg-purple-600 hover:bg-purple-700 text-white flex items-center gap-1"
|
||||
>
|
||||
<FileText className="h-3.5 w-3.5" />
|
||||
文案提取助手
|
||||
</button>
|
||||
<button
|
||||
onClick={onGenerateMeta}
|
||||
disabled={isGeneratingMeta || !text.trim()}
|
||||
className={`px-2 py-1 text-xs rounded transition-all whitespace-nowrap ${isGeneratingMeta || !text.trim()
|
||||
? "bg-gray-600 cursor-not-allowed text-gray-400"
|
||||
: "bg-gradient-to-r from-blue-600 to-cyan-600 hover:from-blue-700 hover:to-cyan-700 text-white"
|
||||
}`}
|
||||
>
|
||||
{isGeneratingMeta ? (
|
||||
<span className="flex items-center gap-1">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
生成中...
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1">
|
||||
<Sparkles className="h-3.5 w-3.5" />
|
||||
AI生成标题标签
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<textarea
|
||||
value={text}
|
||||
onChange={(e) => onChangeText(e.target.value)}
|
||||
placeholder="请输入你想说的话..."
|
||||
className="w-full h-40 bg-black/30 border border-white/10 rounded-xl p-4 text-white placeholder-gray-500 resize-none focus:outline-none focus:border-purple-500 transition-colors hide-scrollbar"
|
||||
/>
|
||||
<div className="flex justify-between mt-2 text-sm text-gray-400">
|
||||
<span>{text.length} 字</span>
|
||||
<span>预计时长: ~{Math.ceil(text.length / 4)} 秒</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
315
frontend/src/components/home/TitleSubtitlePanel.tsx
Normal file
315
frontend/src/components/home/TitleSubtitlePanel.tsx
Normal file
@@ -0,0 +1,315 @@
|
||||
import type { RefObject } from "react";
|
||||
import { Eye } from "lucide-react";
|
||||
|
||||
interface SubtitleStyleOption {
|
||||
id: string;
|
||||
label: string;
|
||||
font_family?: string;
|
||||
font_file?: string;
|
||||
font_size?: number;
|
||||
highlight_color?: string;
|
||||
normal_color?: string;
|
||||
stroke_color?: string;
|
||||
stroke_size?: number;
|
||||
letter_spacing?: number;
|
||||
bottom_margin?: number;
|
||||
is_default?: boolean;
|
||||
}
|
||||
|
||||
interface TitleStyleOption {
|
||||
id: string;
|
||||
label: string;
|
||||
font_family?: string;
|
||||
font_file?: string;
|
||||
font_size?: number;
|
||||
color?: string;
|
||||
stroke_color?: string;
|
||||
stroke_size?: number;
|
||||
letter_spacing?: number;
|
||||
font_weight?: number;
|
||||
top_margin?: number;
|
||||
is_default?: boolean;
|
||||
}
|
||||
|
||||
interface TitleSubtitlePanelProps {
|
||||
showStylePreview: boolean;
|
||||
onTogglePreview: () => void;
|
||||
videoTitle: string;
|
||||
onTitleChange: (value: string) => void;
|
||||
onTitleCompositionStart?: () => void;
|
||||
onTitleCompositionEnd?: (value: string) => void;
|
||||
titleStyles: TitleStyleOption[];
|
||||
selectedTitleStyleId: string;
|
||||
onSelectTitleStyle: (id: string) => void;
|
||||
titleFontSize: number;
|
||||
onTitleFontSizeChange: (value: number) => void;
|
||||
subtitleStyles: SubtitleStyleOption[];
|
||||
selectedSubtitleStyleId: string;
|
||||
onSelectSubtitleStyle: (id: string) => void;
|
||||
subtitleFontSize: number;
|
||||
onSubtitleFontSizeChange: (value: number) => void;
|
||||
enableSubtitles: boolean;
|
||||
onToggleSubtitles: (value: boolean) => void;
|
||||
resolveAssetUrl: (path?: string | null) => string | null;
|
||||
getFontFormat: (fontFile?: string) => string;
|
||||
buildTextShadow: (color: string, size: number) => string;
|
||||
previewScale?: number;
|
||||
previewAspectRatio?: string;
|
||||
previewBaseWidth?: number;
|
||||
previewBaseHeight?: number;
|
||||
previewContainerRef?: RefObject<HTMLDivElement | null>;
|
||||
}
|
||||
|
||||
export function TitleSubtitlePanel({
|
||||
showStylePreview,
|
||||
onTogglePreview,
|
||||
videoTitle,
|
||||
onTitleChange,
|
||||
onTitleCompositionStart,
|
||||
onTitleCompositionEnd,
|
||||
titleStyles,
|
||||
selectedTitleStyleId,
|
||||
onSelectTitleStyle,
|
||||
titleFontSize,
|
||||
onTitleFontSizeChange,
|
||||
subtitleStyles,
|
||||
selectedSubtitleStyleId,
|
||||
onSelectSubtitleStyle,
|
||||
subtitleFontSize,
|
||||
onSubtitleFontSizeChange,
|
||||
enableSubtitles,
|
||||
onToggleSubtitles,
|
||||
resolveAssetUrl,
|
||||
getFontFormat,
|
||||
buildTextShadow,
|
||||
previewScale = 1,
|
||||
previewAspectRatio = '16 / 9',
|
||||
previewBaseWidth = 1280,
|
||||
previewBaseHeight = 720,
|
||||
previewContainerRef,
|
||||
}: TitleSubtitlePanelProps) {
|
||||
const activeSubtitleStyle = subtitleStyles.find((s) => s.id === selectedSubtitleStyleId)
|
||||
|| subtitleStyles.find((s) => s.is_default)
|
||||
|| subtitleStyles[0];
|
||||
|
||||
const activeTitleStyle = titleStyles.find((s) => s.id === selectedTitleStyleId)
|
||||
|| titleStyles.find((s) => s.is_default)
|
||||
|| titleStyles[0];
|
||||
|
||||
const previewTitleText = videoTitle.trim() || "这里是标题预览";
|
||||
const subtitleHighlightText = "最近,一个叫Cloudbot";
|
||||
const subtitleNormalText = "的开源项目在GitHub上彻底火了";
|
||||
|
||||
const subtitleHighlightColor = activeSubtitleStyle?.highlight_color || "#FFE600";
|
||||
const subtitleNormalColor = activeSubtitleStyle?.normal_color || "#FFFFFF";
|
||||
const subtitleStrokeColor = activeSubtitleStyle?.stroke_color || "#000000";
|
||||
const subtitleStrokeSize = activeSubtitleStyle?.stroke_size ?? 3;
|
||||
const subtitleLetterSpacing = activeSubtitleStyle?.letter_spacing ?? 2;
|
||||
const subtitleBottomMargin = activeSubtitleStyle?.bottom_margin ?? 0;
|
||||
const subtitleFontFamilyName = `SubtitlePreview-${activeSubtitleStyle?.id || "default"}`;
|
||||
const subtitleFontUrl = activeSubtitleStyle?.font_file
|
||||
? resolveAssetUrl(`fonts/${activeSubtitleStyle.font_file}`)
|
||||
: null;
|
||||
|
||||
const titleColor = activeTitleStyle?.color || "#FFFFFF";
|
||||
const titleStrokeColor = activeTitleStyle?.stroke_color || "#000000";
|
||||
const titleStrokeSize = activeTitleStyle?.stroke_size ?? 8;
|
||||
const titleLetterSpacing = activeTitleStyle?.letter_spacing ?? 4;
|
||||
const titleTopMargin = activeTitleStyle?.top_margin ?? 0;
|
||||
const titleFontWeight = activeTitleStyle?.font_weight ?? 900;
|
||||
const titleFontFamilyName = `TitlePreview-${activeTitleStyle?.id || "default"}`;
|
||||
const titleFontUrl = activeTitleStyle?.font_file
|
||||
? resolveAssetUrl(`fonts/${activeTitleStyle.font_file}`)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<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>
|
||||
|
||||
{showStylePreview && (
|
||||
<div
|
||||
ref={previewContainerRef}
|
||||
className="mb-4 rounded-xl border border-white/10 bg-black/40 relative overflow-hidden"
|
||||
style={{ aspectRatio: previewAspectRatio, minHeight: '180px' }}
|
||||
>
|
||||
{(titleFontUrl || subtitleFontUrl) && (
|
||||
<style>{`
|
||||
${titleFontUrl ? `@font-face { font-family: '${titleFontFamilyName}'; src: url('${titleFontUrl}') format('${getFontFormat(activeTitleStyle?.font_file)}'); font-weight: 400; font-style: normal; }` : ''}
|
||||
${subtitleFontUrl ? `@font-face { font-family: '${subtitleFontFamilyName}'; src: url('${subtitleFontUrl}') format('${getFontFormat(activeSubtitleStyle?.font_file)}'); font-weight: 400; font-style: normal; }` : ''}
|
||||
`}</style>
|
||||
)}
|
||||
<div className="absolute inset-0 opacity-20 bg-gradient-to-br from-purple-500/40 via-transparent to-pink-500/30" />
|
||||
<div
|
||||
className="absolute top-0 left-0"
|
||||
style={{
|
||||
width: `${previewBaseWidth}px`,
|
||||
height: `${previewBaseHeight}px`,
|
||||
transform: `scale(${previewScale})`,
|
||||
transformOrigin: 'top left',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="w-full text-center"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: `${titleTopMargin}px`,
|
||||
left: 0,
|
||||
right: 0,
|
||||
color: titleColor,
|
||||
fontSize: `${titleFontSize}px`,
|
||||
fontWeight: titleFontWeight,
|
||||
fontFamily: titleFontUrl
|
||||
? `'${titleFontFamilyName}', "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans SC", sans-serif`
|
||||
: '"PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans SC", sans-serif',
|
||||
textShadow: buildTextShadow(titleStrokeColor, titleStrokeSize),
|
||||
letterSpacing: `${titleLetterSpacing}px`,
|
||||
lineHeight: 1.2,
|
||||
opacity: videoTitle.trim() ? 1 : 0.7,
|
||||
padding: '0 5%',
|
||||
}}
|
||||
>
|
||||
{previewTitleText}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="w-full text-center"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: `${subtitleBottomMargin}px`,
|
||||
left: 0,
|
||||
right: 0,
|
||||
fontSize: `${subtitleFontSize}px`,
|
||||
fontFamily: subtitleFontUrl
|
||||
? `'${subtitleFontFamilyName}', "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans SC", sans-serif`
|
||||
: '"PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans SC", sans-serif',
|
||||
textShadow: buildTextShadow(subtitleStrokeColor, subtitleStrokeSize),
|
||||
letterSpacing: `${subtitleLetterSpacing}px`,
|
||||
lineHeight: 1.35,
|
||||
padding: '0 6%',
|
||||
}}
|
||||
>
|
||||
{enableSubtitles ? (
|
||||
<>
|
||||
<span style={{ color: subtitleHighlightColor }}>{subtitleHighlightText}</span>
|
||||
<span style={{ color: subtitleNormalColor }}>{subtitleNormalText}</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-gray-400 text-sm">字幕已关闭</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-4">
|
||||
<label className="text-sm text-gray-300 mb-2 block">片头标题(限制15个字)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={videoTitle}
|
||||
onChange={(e) => onTitleChange(e.target.value)}
|
||||
onCompositionStart={onTitleCompositionStart}
|
||||
onCompositionEnd={(e) => onTitleCompositionEnd?.(e.currentTarget.value)}
|
||||
placeholder="输入视频标题,将在片头显示"
|
||||
className="w-full px-3 sm:px-4 py-2 text-sm sm:text-base bg-black/30 border border-white/10 rounded-xl text-white placeholder-gray-500 focus:outline-none focus:border-purple-500 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{titleStyles.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<label className="text-sm text-gray-300 mb-2 block">标题样式</label>
|
||||
<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="text-white text-sm truncate">{style.label}</div>
|
||||
<div className="text-xs text-gray-400 truncate">
|
||||
{style.font_family || style.font_file || ""}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<label className="text-xs text-gray-400 mb-2 block">标题字号: {titleFontSize}px</label>
|
||||
<input
|
||||
type="range"
|
||||
min="48"
|
||||
max="110"
|
||||
step="1"
|
||||
value={titleFontSize}
|
||||
onChange={(e) => onTitleFontSizeChange(parseInt(e.target.value, 10))}
|
||||
className="w-full accent-purple-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{enableSubtitles && 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="text-white text-sm truncate">{style.label}</div>
|
||||
<div className="text-xs text-gray-400 truncate">
|
||||
{style.font_family || style.font_file || ""}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<label className="text-xs text-gray-400 mb-2 block">字幕字号: {subtitleFontSize}px</label>
|
||||
<input
|
||||
type="range"
|
||||
min="32"
|
||||
max="90"
|
||||
step="1"
|
||||
value={subtitleFontSize}
|
||||
onChange={(e) => onSubtitleFontSizeChange(parseInt(e.target.value, 10))}
|
||||
className="w-full accent-purple-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 pt-4 border-t border-white/10 flex items-center justify-between">
|
||||
<div>
|
||||
<span className="text-sm text-gray-300">逐字高亮字幕</span>
|
||||
<p className="text-xs text-gray-500 mt-1">自动生成卡拉OK效果字幕</p>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enableSubtitles}
|
||||
onChange={(e) => onToggleSubtitles(e.target.checked)}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-11 h-6 bg-gray-600 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-purple-600"></div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
75
frontend/src/components/home/VoiceSelector.tsx
Normal file
75
frontend/src/components/home/VoiceSelector.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Mic, Volume2 } from "lucide-react";
|
||||
|
||||
interface VoiceOption {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface VoiceSelectorProps {
|
||||
ttsMode: "edgetts" | "voiceclone";
|
||||
onSelectTtsMode: (mode: "edgetts" | "voiceclone") => void;
|
||||
voices: VoiceOption[];
|
||||
voice: string;
|
||||
onSelectVoice: (id: string) => void;
|
||||
voiceCloneSlot: ReactNode;
|
||||
}
|
||||
|
||||
export function VoiceSelector({
|
||||
ttsMode,
|
||||
onSelectTtsMode,
|
||||
voices,
|
||||
voice,
|
||||
onSelectVoice,
|
||||
voiceCloneSlot,
|
||||
}: 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>
|
||||
|
||||
<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"
|
||||
? "bg-purple-600 text-white"
|
||||
: "bg-white/10 text-gray-300 hover:bg-white/20"
|
||||
}`}
|
||||
>
|
||||
<Volume2 className="h-4 w-4" />
|
||||
选择声音
|
||||
</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"
|
||||
? "bg-purple-600 text-white"
|
||||
: "bg-white/10 text-gray-300 hover:bg-white/20"
|
||||
}`}
|
||||
>
|
||||
<Mic className="h-4 w-4" />
|
||||
克隆声音
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{ttsMode === "edgetts" && (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{voices.map((v) => (
|
||||
<button
|
||||
key={v.id}
|
||||
onClick={() => onSelectVoice(v.id)}
|
||||
className={`p-3 rounded-xl border-2 transition-all text-left ${voice === v.id
|
||||
? "border-purple-500 bg-purple-500/20"
|
||||
: "border-white/10 bg-white/5 hover:border-white/30"
|
||||
}`}
|
||||
>
|
||||
<span className="text-white text-sm">{v.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{ttsMode === "voiceclone" && voiceCloneSlot}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
55
frontend/src/hooks/useBgm.ts
Normal file
55
frontend/src/hooks/useBgm.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import api from "@/lib/axios";
|
||||
|
||||
export interface BgmItem {
|
||||
id: string;
|
||||
name: string;
|
||||
ext?: string;
|
||||
}
|
||||
|
||||
interface UseBgmOptions {
|
||||
storageKey: string;
|
||||
selectedBgmId: string;
|
||||
setSelectedBgmId: React.Dispatch<React.SetStateAction<string>>;
|
||||
}
|
||||
|
||||
export const useBgm = ({
|
||||
storageKey,
|
||||
selectedBgmId,
|
||||
setSelectedBgmId,
|
||||
}: UseBgmOptions) => {
|
||||
const [bgmList, setBgmList] = useState<BgmItem[]>([]);
|
||||
const [bgmLoading, setBgmLoading] = useState(false);
|
||||
const [bgmError, setBgmError] = useState<string>("");
|
||||
|
||||
const fetchBgmList = useCallback(async () => {
|
||||
setBgmLoading(true);
|
||||
setBgmError("");
|
||||
try {
|
||||
const { data } = await api.get('/api/assets/bgm');
|
||||
const items: BgmItem[] = Array.isArray(data.bgm) ? data.bgm : [];
|
||||
setBgmList(items);
|
||||
|
||||
const savedBgmId = localStorage.getItem(`vigent_${storageKey}_bgmId`);
|
||||
setSelectedBgmId((prev) => {
|
||||
if (prev && items.some((item) => item.id === prev)) return prev;
|
||||
if (savedBgmId && items.some((item) => item.id === savedBgmId)) return savedBgmId;
|
||||
return items[0]?.id || "";
|
||||
});
|
||||
} catch (error: any) {
|
||||
const message = error?.response?.data?.detail || error?.message || '加载失败';
|
||||
setBgmError(message);
|
||||
setBgmList([]);
|
||||
console.error("获取背景音乐失败:", error);
|
||||
} finally {
|
||||
setBgmLoading(false);
|
||||
}
|
||||
}, [setSelectedBgmId, storageKey]);
|
||||
|
||||
return {
|
||||
bgmList,
|
||||
bgmLoading,
|
||||
bgmError,
|
||||
fetchBgmList,
|
||||
};
|
||||
};
|
||||
81
frontend/src/hooks/useGeneratedVideos.ts
Normal file
81
frontend/src/hooks/useGeneratedVideos.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import api from "@/lib/axios";
|
||||
|
||||
interface GeneratedVideo {
|
||||
id: string;
|
||||
name: string;
|
||||
path: string;
|
||||
size_mb: number;
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
interface UseGeneratedVideosOptions {
|
||||
storageKey: string;
|
||||
selectedVideoId: string | null;
|
||||
setSelectedVideoId: React.Dispatch<React.SetStateAction<string | null>>;
|
||||
setGeneratedVideo: React.Dispatch<React.SetStateAction<string | null>>;
|
||||
resolveMediaUrl: (url?: string | null) => string | null;
|
||||
}
|
||||
|
||||
export const useGeneratedVideos = ({
|
||||
storageKey,
|
||||
selectedVideoId,
|
||||
setSelectedVideoId,
|
||||
setGeneratedVideo,
|
||||
resolveMediaUrl,
|
||||
}: UseGeneratedVideosOptions) => {
|
||||
const [generatedVideos, setGeneratedVideos] = useState<GeneratedVideo[]>([]);
|
||||
|
||||
const fetchGeneratedVideos = useCallback(async (preferVideoId?: string) => {
|
||||
try {
|
||||
const { data } = await api.get('/api/videos/generated');
|
||||
const videos: GeneratedVideo[] = data.videos || [];
|
||||
setGeneratedVideos(videos);
|
||||
|
||||
const savedSelectedVideoId = localStorage.getItem(`vigent_${storageKey}_selectedVideoId`);
|
||||
const currentId = preferVideoId || selectedVideoId || savedSelectedVideoId || null;
|
||||
let nextId: string | null = null;
|
||||
let nextUrl: string | null = null;
|
||||
|
||||
if (currentId) {
|
||||
const found = videos.find(v => v.id === currentId);
|
||||
if (found) {
|
||||
nextId = found.id;
|
||||
nextUrl = resolveMediaUrl(found.path);
|
||||
}
|
||||
}
|
||||
|
||||
if (!nextId && videos.length > 0) {
|
||||
nextId = videos[0].id;
|
||||
nextUrl = resolveMediaUrl(videos[0].path);
|
||||
}
|
||||
|
||||
if (nextId) {
|
||||
setSelectedVideoId(nextId);
|
||||
setGeneratedVideo(nextUrl);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("获取历史视频失败:", error);
|
||||
}
|
||||
}, [resolveMediaUrl, selectedVideoId, setGeneratedVideo, setSelectedVideoId, storageKey]);
|
||||
|
||||
const deleteVideo = useCallback(async (videoId: string) => {
|
||||
if (!confirm("确定要删除这个视频吗?")) return;
|
||||
try {
|
||||
await api.delete(`/api/videos/generated/${videoId}`);
|
||||
if (selectedVideoId === videoId) {
|
||||
setSelectedVideoId(null);
|
||||
setGeneratedVideo(null);
|
||||
}
|
||||
fetchGeneratedVideos();
|
||||
} catch (error) {
|
||||
alert("删除失败: " + error);
|
||||
}
|
||||
}, [fetchGeneratedVideos, selectedVideoId, setGeneratedVideo, setSelectedVideoId]);
|
||||
|
||||
return {
|
||||
generatedVideos,
|
||||
fetchGeneratedVideos,
|
||||
deleteVideo,
|
||||
};
|
||||
};
|
||||
251
frontend/src/hooks/useHomePersistence.ts
Normal file
251
frontend/src/hooks/useHomePersistence.ts
Normal file
@@ -0,0 +1,251 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { clampTitle } from "@/lib/title";
|
||||
|
||||
interface RefAudio {
|
||||
id: string;
|
||||
name: string;
|
||||
path: string;
|
||||
ref_text: string;
|
||||
duration_sec: number;
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
interface UseHomePersistenceOptions {
|
||||
isAuthLoading: boolean;
|
||||
storageKey: string;
|
||||
text: string;
|
||||
setText: React.Dispatch<React.SetStateAction<string>>;
|
||||
videoTitle: string;
|
||||
setVideoTitle: React.Dispatch<React.SetStateAction<string>>;
|
||||
enableSubtitles: boolean;
|
||||
setEnableSubtitles: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
ttsMode: 'edgetts' | 'voiceclone';
|
||||
setTtsMode: React.Dispatch<React.SetStateAction<'edgetts' | 'voiceclone'>>;
|
||||
voice: string;
|
||||
setVoice: React.Dispatch<React.SetStateAction<string>>;
|
||||
selectedMaterial: string;
|
||||
setSelectedMaterial: React.Dispatch<React.SetStateAction<string>>;
|
||||
selectedSubtitleStyleId: string;
|
||||
setSelectedSubtitleStyleId: React.Dispatch<React.SetStateAction<string>>;
|
||||
selectedTitleStyleId: string;
|
||||
setSelectedTitleStyleId: React.Dispatch<React.SetStateAction<string>>;
|
||||
subtitleFontSize: number;
|
||||
setSubtitleFontSize: React.Dispatch<React.SetStateAction<number>>;
|
||||
titleFontSize: number;
|
||||
setTitleFontSize: React.Dispatch<React.SetStateAction<number>>;
|
||||
setSubtitleSizeLocked: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
setTitleSizeLocked: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
selectedBgmId: string;
|
||||
setSelectedBgmId: React.Dispatch<React.SetStateAction<string>>;
|
||||
bgmVolume: number;
|
||||
setBgmVolume: React.Dispatch<React.SetStateAction<number>>;
|
||||
enableBgm: boolean;
|
||||
setEnableBgm: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
selectedVideoId: string | null;
|
||||
setSelectedVideoId: React.Dispatch<React.SetStateAction<string | null>>;
|
||||
selectedRefAudio: RefAudio | null;
|
||||
}
|
||||
|
||||
export const useHomePersistence = ({
|
||||
isAuthLoading,
|
||||
storageKey,
|
||||
text,
|
||||
setText,
|
||||
videoTitle,
|
||||
setVideoTitle,
|
||||
enableSubtitles,
|
||||
setEnableSubtitles,
|
||||
ttsMode,
|
||||
setTtsMode,
|
||||
voice,
|
||||
setVoice,
|
||||
selectedMaterial,
|
||||
setSelectedMaterial,
|
||||
selectedSubtitleStyleId,
|
||||
setSelectedSubtitleStyleId,
|
||||
selectedTitleStyleId,
|
||||
setSelectedTitleStyleId,
|
||||
subtitleFontSize,
|
||||
setSubtitleFontSize,
|
||||
titleFontSize,
|
||||
setTitleFontSize,
|
||||
setSubtitleSizeLocked,
|
||||
setTitleSizeLocked,
|
||||
selectedBgmId,
|
||||
setSelectedBgmId,
|
||||
bgmVolume,
|
||||
setBgmVolume,
|
||||
enableBgm,
|
||||
setEnableBgm,
|
||||
selectedVideoId,
|
||||
setSelectedVideoId,
|
||||
selectedRefAudio,
|
||||
}: UseHomePersistenceOptions) => {
|
||||
const [isRestored, setIsRestored] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthLoading) return;
|
||||
|
||||
const savedText = localStorage.getItem(`vigent_${storageKey}_text`);
|
||||
const savedTitle = localStorage.getItem(`vigent_${storageKey}_title`);
|
||||
const savedSubtitles = localStorage.getItem(`vigent_${storageKey}_subtitles`);
|
||||
const savedTtsMode = localStorage.getItem(`vigent_${storageKey}_ttsMode`);
|
||||
const savedVoice = localStorage.getItem(`vigent_${storageKey}_voice`);
|
||||
const savedMaterial = localStorage.getItem(`vigent_${storageKey}_material`);
|
||||
const savedSubtitleStyle = localStorage.getItem(`vigent_${storageKey}_subtitleStyle`);
|
||||
const savedTitleStyle = localStorage.getItem(`vigent_${storageKey}_titleStyle`);
|
||||
const savedSubtitleFontSize = localStorage.getItem(`vigent_${storageKey}_subtitleFontSize`);
|
||||
const savedTitleFontSize = localStorage.getItem(`vigent_${storageKey}_titleFontSize`);
|
||||
const savedBgmId = localStorage.getItem(`vigent_${storageKey}_bgmId`);
|
||||
const savedSelectedVideoId = localStorage.getItem(`vigent_${storageKey}_selectedVideoId`);
|
||||
const savedBgmVolume = localStorage.getItem(`vigent_${storageKey}_bgmVolume`);
|
||||
const savedEnableBgm = localStorage.getItem(`vigent_${storageKey}_enableBgm`);
|
||||
|
||||
setText(savedText || "大家好,欢迎来到我的频道,今天给大家分享一些有趣的内容。");
|
||||
setVideoTitle(savedTitle ? clampTitle(savedTitle) : "");
|
||||
setEnableSubtitles(savedSubtitles !== null ? savedSubtitles === 'true' : true);
|
||||
setTtsMode((savedTtsMode as 'edgetts' | 'voiceclone') || 'edgetts');
|
||||
setVoice(savedVoice || "zh-CN-YunxiNeural");
|
||||
|
||||
if (savedMaterial) setSelectedMaterial(savedMaterial);
|
||||
if (savedSubtitleStyle) setSelectedSubtitleStyleId(savedSubtitleStyle);
|
||||
if (savedTitleStyle) setSelectedTitleStyleId(savedTitleStyle);
|
||||
|
||||
if (savedSubtitleFontSize) {
|
||||
const parsed = parseInt(savedSubtitleFontSize, 10);
|
||||
if (!Number.isNaN(parsed)) {
|
||||
setSubtitleFontSize(parsed);
|
||||
setSubtitleSizeLocked(true);
|
||||
}
|
||||
}
|
||||
|
||||
if (savedTitleFontSize) {
|
||||
const parsed = parseInt(savedTitleFontSize, 10);
|
||||
if (!Number.isNaN(parsed)) {
|
||||
setTitleFontSize(parsed);
|
||||
setTitleSizeLocked(true);
|
||||
}
|
||||
}
|
||||
|
||||
if (savedBgmId) setSelectedBgmId(savedBgmId);
|
||||
if (savedBgmVolume) setBgmVolume(parseFloat(savedBgmVolume));
|
||||
if (savedEnableBgm !== null) setEnableBgm(savedEnableBgm === 'true');
|
||||
if (savedSelectedVideoId) setSelectedVideoId(savedSelectedVideoId);
|
||||
|
||||
setIsRestored(true);
|
||||
}, [
|
||||
isAuthLoading,
|
||||
setBgmVolume,
|
||||
setEnableBgm,
|
||||
setEnableSubtitles,
|
||||
setSelectedBgmId,
|
||||
setSelectedMaterial,
|
||||
setSelectedSubtitleStyleId,
|
||||
setSelectedTitleStyleId,
|
||||
setSelectedVideoId,
|
||||
setSubtitleFontSize,
|
||||
setSubtitleSizeLocked,
|
||||
setText,
|
||||
setTitleFontSize,
|
||||
setTitleSizeLocked,
|
||||
setTtsMode,
|
||||
setVideoTitle,
|
||||
setVoice,
|
||||
storageKey,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isRestored) return;
|
||||
const timeout = setTimeout(() => {
|
||||
localStorage.setItem(`vigent_${storageKey}_text`, text);
|
||||
}, 300);
|
||||
return () => clearTimeout(timeout);
|
||||
}, [text, storageKey, isRestored]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isRestored) return;
|
||||
const timeout = setTimeout(() => {
|
||||
localStorage.setItem(`vigent_${storageKey}_title`, videoTitle);
|
||||
}, 300);
|
||||
return () => clearTimeout(timeout);
|
||||
}, [videoTitle, storageKey, isRestored]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isRestored) localStorage.setItem(`vigent_${storageKey}_subtitles`, String(enableSubtitles));
|
||||
}, [enableSubtitles, storageKey, isRestored]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isRestored) localStorage.setItem(`vigent_${storageKey}_ttsMode`, ttsMode);
|
||||
}, [ttsMode, storageKey, isRestored]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isRestored) localStorage.setItem(`vigent_${storageKey}_voice`, voice);
|
||||
}, [voice, storageKey, isRestored]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isRestored && selectedMaterial) {
|
||||
localStorage.setItem(`vigent_${storageKey}_material`, selectedMaterial);
|
||||
}
|
||||
}, [selectedMaterial, storageKey, isRestored]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isRestored && selectedSubtitleStyleId) {
|
||||
localStorage.setItem(`vigent_${storageKey}_subtitleStyle`, selectedSubtitleStyleId);
|
||||
}
|
||||
}, [selectedSubtitleStyleId, storageKey, isRestored]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isRestored && selectedTitleStyleId) {
|
||||
localStorage.setItem(`vigent_${storageKey}_titleStyle`, selectedTitleStyleId);
|
||||
}
|
||||
}, [selectedTitleStyleId, storageKey, isRestored]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isRestored) {
|
||||
localStorage.setItem(`vigent_${storageKey}_subtitleFontSize`, String(subtitleFontSize));
|
||||
}
|
||||
}, [subtitleFontSize, storageKey, isRestored]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isRestored) {
|
||||
localStorage.setItem(`vigent_${storageKey}_titleFontSize`, String(titleFontSize));
|
||||
}
|
||||
}, [titleFontSize, storageKey, isRestored]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isRestored) {
|
||||
localStorage.setItem(`vigent_${storageKey}_bgmId`, selectedBgmId);
|
||||
}
|
||||
}, [selectedBgmId, storageKey, isRestored]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isRestored) return;
|
||||
const timeout = setTimeout(() => {
|
||||
localStorage.setItem(`vigent_${storageKey}_bgmVolume`, String(bgmVolume));
|
||||
}, 300);
|
||||
return () => clearTimeout(timeout);
|
||||
}, [bgmVolume, storageKey, isRestored]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isRestored) {
|
||||
localStorage.setItem(`vigent_${storageKey}_enableBgm`, String(enableBgm));
|
||||
}
|
||||
}, [enableBgm, storageKey, isRestored]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isRestored) return;
|
||||
if (selectedVideoId) {
|
||||
localStorage.setItem(`vigent_${storageKey}_selectedVideoId`, selectedVideoId);
|
||||
} else {
|
||||
localStorage.removeItem(`vigent_${storageKey}_selectedVideoId`);
|
||||
}
|
||||
}, [selectedVideoId, storageKey, isRestored]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isRestored && selectedRefAudio) {
|
||||
localStorage.setItem(`vigent_${storageKey}_refAudioId`, selectedRefAudio.id);
|
||||
}
|
||||
}, [selectedRefAudio, storageKey, isRestored]);
|
||||
|
||||
return { isRestored };
|
||||
};
|
||||
113
frontend/src/hooks/useMaterials.ts
Normal file
113
frontend/src/hooks/useMaterials.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import api from "@/lib/axios";
|
||||
|
||||
interface Material {
|
||||
id: string;
|
||||
name: string;
|
||||
scene: string;
|
||||
size_mb: number;
|
||||
path: string;
|
||||
}
|
||||
|
||||
interface UseMaterialsOptions {
|
||||
selectedMaterial: string;
|
||||
setSelectedMaterial: React.Dispatch<React.SetStateAction<string>>;
|
||||
}
|
||||
|
||||
export const useMaterials = ({
|
||||
selectedMaterial,
|
||||
setSelectedMaterial,
|
||||
}: UseMaterialsOptions) => {
|
||||
const [materials, setMaterials] = useState<Material[]>([]);
|
||||
const [fetchError, setFetchError] = useState<string | null>(null);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [uploadProgress, setUploadProgress] = useState(0);
|
||||
const [uploadError, setUploadError] = useState<string | null>(null);
|
||||
|
||||
const fetchMaterials = useCallback(async () => {
|
||||
try {
|
||||
setFetchError(null);
|
||||
|
||||
const { data } = await api.get(`/api/materials?t=${new Date().getTime()}`);
|
||||
const nextMaterials = data.materials || [];
|
||||
setMaterials(nextMaterials);
|
||||
|
||||
const nextSelected = nextMaterials.find((item: Material) => item.id === selectedMaterial)?.id
|
||||
|| nextMaterials[0]?.id
|
||||
|| "";
|
||||
if (nextSelected !== selectedMaterial) {
|
||||
setSelectedMaterial(nextSelected);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("获取素材失败:", error);
|
||||
setFetchError(String(error));
|
||||
}
|
||||
}, [selectedMaterial, setSelectedMaterial]);
|
||||
|
||||
const deleteMaterial = useCallback(async (materialId: string) => {
|
||||
if (!confirm("确定要删除这个素材吗?")) return;
|
||||
try {
|
||||
await api.delete(`/api/materials/${materialId}`);
|
||||
fetchMaterials();
|
||||
if (selectedMaterial === materialId) {
|
||||
setSelectedMaterial("");
|
||||
}
|
||||
} catch (error) {
|
||||
alert("删除失败: " + error);
|
||||
}
|
||||
}, [fetchMaterials, selectedMaterial, setSelectedMaterial]);
|
||||
|
||||
const handleUpload = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const validTypes = ['.mp4', '.mov', '.avi'];
|
||||
const ext = file.name.toLowerCase().slice(file.name.lastIndexOf('.'));
|
||||
if (!validTypes.includes(ext)) {
|
||||
setUploadError('仅支持 MP4、MOV、AVI 格式');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsUploading(true);
|
||||
setUploadProgress(0);
|
||||
setUploadError(null);
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
await api.post('/api/materials', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
onUploadProgress: (progressEvent) => {
|
||||
if (progressEvent.total) {
|
||||
const progress = Math.round((progressEvent.loaded / progressEvent.total) * 100);
|
||||
setUploadProgress(progress);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
setUploadProgress(100);
|
||||
setIsUploading(false);
|
||||
fetchMaterials();
|
||||
} catch (err: any) {
|
||||
console.error("Upload failed:", err);
|
||||
setIsUploading(false);
|
||||
const errorMsg = err.response?.data?.detail || err.message || String(err);
|
||||
setUploadError(`上传失败: ${errorMsg}`);
|
||||
}
|
||||
|
||||
e.target.value = '';
|
||||
}, [fetchMaterials]);
|
||||
|
||||
return {
|
||||
materials,
|
||||
fetchError,
|
||||
isUploading,
|
||||
uploadProgress,
|
||||
uploadError,
|
||||
setUploadError,
|
||||
fetchMaterials,
|
||||
deleteMaterial,
|
||||
handleUpload,
|
||||
};
|
||||
};
|
||||
116
frontend/src/hooks/useMediaPlayers.ts
Normal file
116
frontend/src/hooks/useMediaPlayers.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { BgmItem } from "@/hooks/useBgm";
|
||||
|
||||
interface RefAudio {
|
||||
id: string;
|
||||
name: string;
|
||||
path: string;
|
||||
ref_text: string;
|
||||
duration_sec: number;
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
interface UseMediaPlayersOptions {
|
||||
bgmVolume: number;
|
||||
resolveBgmUrl: (bgmId?: string | null) => string | null;
|
||||
resolveMediaUrl: (url?: string | null) => string | null;
|
||||
setSelectedBgmId: React.Dispatch<React.SetStateAction<string>>;
|
||||
setEnableBgm: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
}
|
||||
|
||||
export const useMediaPlayers = ({
|
||||
bgmVolume,
|
||||
resolveBgmUrl,
|
||||
resolveMediaUrl,
|
||||
setSelectedBgmId,
|
||||
setEnableBgm,
|
||||
}: UseMediaPlayersOptions) => {
|
||||
const [playingAudioId, setPlayingAudioId] = useState<string | null>(null);
|
||||
const [playingBgmId, setPlayingBgmId] = useState<string | null>(null);
|
||||
const audioPlayerRef = useRef<HTMLAudioElement | null>(null);
|
||||
const bgmPlayerRef = useRef<HTMLAudioElement | null>(null);
|
||||
|
||||
const stopAudio = useCallback(() => {
|
||||
if (audioPlayerRef.current) {
|
||||
audioPlayerRef.current.pause();
|
||||
audioPlayerRef.current.currentTime = 0;
|
||||
audioPlayerRef.current = null;
|
||||
}
|
||||
setPlayingAudioId(null);
|
||||
}, []);
|
||||
|
||||
const stopBgm = useCallback(() => {
|
||||
if (bgmPlayerRef.current) {
|
||||
bgmPlayerRef.current.pause();
|
||||
bgmPlayerRef.current.currentTime = 0;
|
||||
bgmPlayerRef.current = null;
|
||||
}
|
||||
setPlayingBgmId(null);
|
||||
}, []);
|
||||
|
||||
const togglePlayPreview = useCallback((audio: RefAudio, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
|
||||
if (bgmPlayerRef.current) {
|
||||
stopBgm();
|
||||
}
|
||||
|
||||
if (playingAudioId === audio.id) {
|
||||
stopAudio();
|
||||
return;
|
||||
}
|
||||
|
||||
stopAudio();
|
||||
|
||||
const audioUrl = resolveMediaUrl(audio.path) || audio.path;
|
||||
if (!audioUrl) {
|
||||
alert("无法播放该参考音频");
|
||||
return;
|
||||
}
|
||||
const player = new Audio(audioUrl);
|
||||
player.onended = () => setPlayingAudioId(null);
|
||||
player.play().catch((err) => alert("播放失败: " + err));
|
||||
audioPlayerRef.current = player;
|
||||
setPlayingAudioId(audio.id);
|
||||
}, [playingAudioId, resolveMediaUrl, stopAudio, stopBgm]);
|
||||
|
||||
const toggleBgmPreview = useCallback((bgm: BgmItem, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setSelectedBgmId(bgm.id);
|
||||
setEnableBgm(true);
|
||||
|
||||
const bgmUrl = resolveBgmUrl(bgm.id);
|
||||
if (!bgmUrl) {
|
||||
alert("无法播放该背景音乐");
|
||||
return;
|
||||
}
|
||||
|
||||
if (playingBgmId === bgm.id) {
|
||||
stopBgm();
|
||||
return;
|
||||
}
|
||||
|
||||
stopAudio();
|
||||
stopBgm();
|
||||
|
||||
const player = new Audio(bgmUrl);
|
||||
player.volume = Math.max(0, Math.min(bgmVolume, 1));
|
||||
player.onended = () => setPlayingBgmId(null);
|
||||
player.play().catch((err) => alert("播放失败: " + err));
|
||||
bgmPlayerRef.current = player;
|
||||
setPlayingBgmId(bgm.id);
|
||||
}, [bgmVolume, playingBgmId, resolveBgmUrl, setEnableBgm, setSelectedBgmId, stopAudio, stopBgm]);
|
||||
|
||||
useEffect(() => {
|
||||
if (bgmPlayerRef.current) {
|
||||
bgmPlayerRef.current.volume = Math.max(0, Math.min(bgmVolume, 1));
|
||||
}
|
||||
}, [bgmVolume]);
|
||||
|
||||
return {
|
||||
playingAudioId,
|
||||
playingBgmId,
|
||||
togglePlayPreview,
|
||||
toggleBgmPreview,
|
||||
};
|
||||
};
|
||||
91
frontend/src/hooks/useRefAudios.ts
Normal file
91
frontend/src/hooks/useRefAudios.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import api from "@/lib/axios";
|
||||
|
||||
interface RefAudio {
|
||||
id: string;
|
||||
name: string;
|
||||
path: string;
|
||||
ref_text: string;
|
||||
duration_sec: number;
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
interface UseRefAudiosOptions {
|
||||
fixedRefText: string;
|
||||
selectedRefAudio: RefAudio | null;
|
||||
setSelectedRefAudio: React.Dispatch<React.SetStateAction<RefAudio | null>>;
|
||||
setRefText: React.Dispatch<React.SetStateAction<string>>;
|
||||
}
|
||||
|
||||
export const useRefAudios = ({
|
||||
fixedRefText,
|
||||
selectedRefAudio,
|
||||
setSelectedRefAudio,
|
||||
setRefText,
|
||||
}: UseRefAudiosOptions) => {
|
||||
const [refAudios, setRefAudios] = useState<RefAudio[]>([]);
|
||||
const [isUploadingRef, setIsUploadingRef] = useState(false);
|
||||
const [uploadRefError, setUploadRefError] = useState<string | null>(null);
|
||||
|
||||
const fetchRefAudios = useCallback(async () => {
|
||||
try {
|
||||
const { data } = await api.get('/api/ref-audios');
|
||||
const items: RefAudio[] = data.items || [];
|
||||
items.sort((a, b) => b.created_at - a.created_at);
|
||||
setRefAudios(items);
|
||||
} catch (error) {
|
||||
console.error("获取参考音频失败:", error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const uploadRefAudio = useCallback(async (file: File) => {
|
||||
const refTextInput = fixedRefText;
|
||||
|
||||
setIsUploadingRef(true);
|
||||
setUploadRefError(null);
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('ref_text', refTextInput);
|
||||
|
||||
const { data } = await api.post('/api/ref-audios', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
|
||||
await fetchRefAudios();
|
||||
setSelectedRefAudio(data);
|
||||
setRefText(data.ref_text);
|
||||
setIsUploadingRef(false);
|
||||
} catch (err: any) {
|
||||
console.error("Upload ref audio failed:", err);
|
||||
setIsUploadingRef(false);
|
||||
const errorMsg = err.response?.data?.detail || err.message || String(err);
|
||||
setUploadRefError(`上传失败: ${errorMsg}`);
|
||||
}
|
||||
}, [fetchRefAudios, fixedRefText, setRefText, setSelectedRefAudio]);
|
||||
|
||||
const deleteRefAudio = useCallback(async (audioId: string) => {
|
||||
if (!confirm("确定要删除这个参考音频吗?")) return;
|
||||
try {
|
||||
await api.delete(`/api/ref-audios/${encodeURIComponent(audioId)}`);
|
||||
fetchRefAudios();
|
||||
if (selectedRefAudio?.id === audioId) {
|
||||
setSelectedRefAudio(null);
|
||||
setRefText('');
|
||||
}
|
||||
} catch (error) {
|
||||
alert("删除失败: " + error);
|
||||
}
|
||||
}, [fetchRefAudios, selectedRefAudio, setRefText, setSelectedRefAudio]);
|
||||
|
||||
return {
|
||||
refAudios,
|
||||
isUploadingRef,
|
||||
uploadRefError,
|
||||
setUploadRefError,
|
||||
fetchRefAudios,
|
||||
uploadRefAudio,
|
||||
deleteRefAudio,
|
||||
};
|
||||
};
|
||||
66
frontend/src/hooks/useTitleInput.ts
Normal file
66
frontend/src/hooks/useTitleInput.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { applyTitleLimit, TITLE_MAX_LENGTH } from "@/lib/title";
|
||||
|
||||
interface UseTitleInputOptions {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
onCommit?: (value: string) => void;
|
||||
maxLength?: number;
|
||||
}
|
||||
|
||||
export const useTitleInput = ({
|
||||
value,
|
||||
onChange,
|
||||
onCommit,
|
||||
maxLength = TITLE_MAX_LENGTH,
|
||||
}: UseTitleInputOptions) => {
|
||||
const isComposingRef = useRef(false);
|
||||
const committedRef = useRef(value);
|
||||
|
||||
useEffect(() => {
|
||||
if (isComposingRef.current) return;
|
||||
committedRef.current = value;
|
||||
}, [value]);
|
||||
|
||||
const commitValue = useCallback(
|
||||
(nextValue: string) => {
|
||||
committedRef.current = nextValue;
|
||||
onChange(nextValue);
|
||||
onCommit?.(nextValue);
|
||||
},
|
||||
[onChange, onCommit]
|
||||
);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(nextValue: string) => {
|
||||
if (isComposingRef.current) {
|
||||
onChange(nextValue);
|
||||
return;
|
||||
}
|
||||
const limited = applyTitleLimit(committedRef.current, nextValue, maxLength);
|
||||
commitValue(limited);
|
||||
},
|
||||
[maxLength, onChange, commitValue]
|
||||
);
|
||||
|
||||
const handleCompositionStart = useCallback(() => {
|
||||
isComposingRef.current = true;
|
||||
}, []);
|
||||
|
||||
const handleCompositionEnd = useCallback(
|
||||
(nextValue: string) => {
|
||||
isComposingRef.current = false;
|
||||
const limited = applyTitleLimit(committedRef.current, nextValue, maxLength);
|
||||
commitValue(limited);
|
||||
},
|
||||
[maxLength, commitValue]
|
||||
);
|
||||
|
||||
return {
|
||||
handleChange,
|
||||
handleCompositionStart,
|
||||
handleCompositionEnd,
|
||||
commitValue,
|
||||
maxLength,
|
||||
};
|
||||
};
|
||||
98
frontend/src/hooks/useTitleSubtitleStyles.ts
Normal file
98
frontend/src/hooks/useTitleSubtitleStyles.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import api from "@/lib/axios";
|
||||
|
||||
export interface SubtitleStyleOption {
|
||||
id: string;
|
||||
label: string;
|
||||
font_family?: string;
|
||||
font_file?: string;
|
||||
font_size?: number;
|
||||
highlight_color?: string;
|
||||
normal_color?: string;
|
||||
stroke_color?: string;
|
||||
stroke_size?: number;
|
||||
letter_spacing?: number;
|
||||
bottom_margin?: number;
|
||||
is_default?: boolean;
|
||||
}
|
||||
|
||||
export interface TitleStyleOption {
|
||||
id: string;
|
||||
label: string;
|
||||
font_family?: string;
|
||||
font_file?: string;
|
||||
font_size?: number;
|
||||
color?: string;
|
||||
stroke_color?: string;
|
||||
stroke_size?: number;
|
||||
letter_spacing?: number;
|
||||
font_weight?: number;
|
||||
top_margin?: number;
|
||||
is_default?: boolean;
|
||||
}
|
||||
|
||||
interface UseTitleSubtitleStylesOptions {
|
||||
isAuthLoading: boolean;
|
||||
storageKey: string;
|
||||
setSelectedSubtitleStyleId: React.Dispatch<React.SetStateAction<string>>;
|
||||
setSelectedTitleStyleId: React.Dispatch<React.SetStateAction<string>>;
|
||||
}
|
||||
|
||||
export const useTitleSubtitleStyles = ({
|
||||
isAuthLoading,
|
||||
storageKey,
|
||||
setSelectedSubtitleStyleId,
|
||||
setSelectedTitleStyleId,
|
||||
}: UseTitleSubtitleStylesOptions) => {
|
||||
const [subtitleStyles, setSubtitleStyles] = useState<SubtitleStyleOption[]>([]);
|
||||
const [titleStyles, setTitleStyles] = useState<TitleStyleOption[]>([]);
|
||||
|
||||
const refreshSubtitleStyles = useCallback(async () => {
|
||||
try {
|
||||
const { data } = await api.get('/api/assets/subtitle-styles');
|
||||
const styles: SubtitleStyleOption[] = data.styles || [];
|
||||
setSubtitleStyles(styles);
|
||||
|
||||
const savedStyleId = localStorage.getItem(`vigent_${storageKey}_subtitleStyle`);
|
||||
setSelectedSubtitleStyleId((prev) => {
|
||||
if (prev && styles.some((s) => s.id === prev)) return prev;
|
||||
if (savedStyleId && styles.some((s) => s.id === savedStyleId)) return savedStyleId;
|
||||
const defaultStyle = styles.find((s) => s.is_default) || styles[0];
|
||||
return defaultStyle?.id || "";
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("获取字幕样式失败:", error);
|
||||
}
|
||||
}, [setSelectedSubtitleStyleId, storageKey]);
|
||||
|
||||
const refreshTitleStyles = useCallback(async () => {
|
||||
try {
|
||||
const { data } = await api.get('/api/assets/title-styles');
|
||||
const styles: TitleStyleOption[] = data.styles || [];
|
||||
setTitleStyles(styles);
|
||||
|
||||
const savedStyleId = localStorage.getItem(`vigent_${storageKey}_titleStyle`);
|
||||
setSelectedTitleStyleId((prev) => {
|
||||
if (prev && styles.some((s) => s.id === prev)) return prev;
|
||||
if (savedStyleId && styles.some((s) => s.id === savedStyleId)) return savedStyleId;
|
||||
const defaultStyle = styles.find((s) => s.is_default) || styles[0];
|
||||
return defaultStyle?.id || "";
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("获取标题样式失败:", error);
|
||||
}
|
||||
}, [setSelectedTitleStyleId, storageKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthLoading) return;
|
||||
refreshSubtitleStyles();
|
||||
refreshTitleStyles();
|
||||
}, [isAuthLoading, refreshSubtitleStyles, refreshTitleStyles]);
|
||||
|
||||
return {
|
||||
subtitleStyles,
|
||||
titleStyles,
|
||||
refreshSubtitleStyles,
|
||||
refreshTitleStyles,
|
||||
};
|
||||
};
|
||||
61
frontend/src/lib/media.ts
Normal file
61
frontend/src/lib/media.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
const DEFAULT_API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:8006';
|
||||
|
||||
export const getApiBaseUrl = () => {
|
||||
return typeof window === 'undefined' ? DEFAULT_API_BASE : '';
|
||||
};
|
||||
|
||||
export const isAbsoluteUrl = (url: string) => /^https?:\/\//i.test(url);
|
||||
|
||||
export const joinBaseUrl = (base: string, path: string) => {
|
||||
if (!base) return path;
|
||||
if (!path.startsWith('/')) return `${base}/${path}`;
|
||||
return `${base}${path}`;
|
||||
};
|
||||
|
||||
export const resolveMediaUrl = (url?: string | null) => {
|
||||
if (!url) return null;
|
||||
if (isAbsoluteUrl(url)) return url;
|
||||
return joinBaseUrl(getApiBaseUrl(), url);
|
||||
};
|
||||
|
||||
export const encodePathSegments = (value: string) =>
|
||||
value.split('/').map(encodeURIComponent).join('/');
|
||||
|
||||
export const resolveAssetUrl = (assetPath?: string | null) => {
|
||||
if (!assetPath) return null;
|
||||
const encoded = encodePathSegments(assetPath);
|
||||
return joinBaseUrl(getApiBaseUrl(), `/assets/${encoded}`);
|
||||
};
|
||||
|
||||
export const resolveBgmUrl = (bgmId?: string | null) => {
|
||||
if (!bgmId) return null;
|
||||
return resolveAssetUrl(`bgm/${bgmId}`);
|
||||
};
|
||||
|
||||
export const getFontFormat = (fontFile?: string) => {
|
||||
if (!fontFile) return 'truetype';
|
||||
const ext = fontFile.split('.').pop()?.toLowerCase();
|
||||
if (ext === 'otf') return 'opentype';
|
||||
return 'truetype';
|
||||
};
|
||||
|
||||
export const buildTextShadow = (color: string, size: number) => {
|
||||
return [
|
||||
`-${size}px -${size}px 0 ${color}`,
|
||||
`${size}px -${size}px 0 ${color}`,
|
||||
`-${size}px ${size}px 0 ${color}`,
|
||||
`${size}px ${size}px 0 ${color}`,
|
||||
`0 0 ${size * 4}px rgba(0,0,0,0.9)`,
|
||||
`0 4px 8px rgba(0,0,0,0.6)`
|
||||
].join(',');
|
||||
};
|
||||
|
||||
export const formatDate = (timestamp: number) => {
|
||||
const d = new Date(timestamp * 1000);
|
||||
const year = d.getFullYear();
|
||||
const month = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
const hour = String(d.getHours()).padStart(2, '0');
|
||||
const minute = String(d.getMinutes()).padStart(2, '0');
|
||||
return `${year}/${month}/${day} ${hour}:${minute}`;
|
||||
};
|
||||
14
frontend/src/lib/title.ts
Normal file
14
frontend/src/lib/title.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export const TITLE_MAX_LENGTH = 15;
|
||||
|
||||
export const clampTitle = (value: string, maxLength: number = TITLE_MAX_LENGTH) =>
|
||||
value.slice(0, maxLength);
|
||||
|
||||
export const applyTitleLimit = (
|
||||
prev: string,
|
||||
next: string,
|
||||
maxLength: number = TITLE_MAX_LENGTH
|
||||
) => {
|
||||
if (next.length <= maxLength) return next;
|
||||
if (prev.length >= maxLength) return prev;
|
||||
return next.slice(0, maxLength);
|
||||
};
|
||||
Reference in New Issue
Block a user