更新
This commit is contained in:
@@ -1,5 +1,3 @@
|
|||||||
---
|
|
||||||
|
|
||||||
## 🔧 Qwen-TTS Flash Attention 优化 (10:00)
|
## 🔧 Qwen-TTS Flash Attention 优化 (10:00)
|
||||||
|
|
||||||
### 优化背景
|
### 优化背景
|
||||||
@@ -18,8 +16,6 @@ pip install -U flash-attn --no-build-isolation
|
|||||||
- **显存占用**: 显著降低,消除 OOM 风险
|
- **显存占用**: 显著降低,消除 OOM 风险
|
||||||
- **代码变动**: 无代码变动,仅环境优化 (自动检测)
|
- **代码变动**: 无代码变动,仅环境优化 (自动检测)
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🛡️ 服务看门狗 Watchdog (10:30)
|
## 🛡️ 服务看门狗 Watchdog (10:30)
|
||||||
|
|
||||||
### 问题描述
|
### 问题描述
|
||||||
@@ -53,67 +49,20 @@ if service["failures"] >= service['threshold']:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🎨 UI 交互体验优化 (15:30)
|
## 🎯 前端按钮图标统一 (16:40)
|
||||||
|
|
||||||
### 优化内容
|
### 内容
|
||||||
- 视频生成完成后,预览优先选中最新输出
|
- 首页与发布页按钮图标统一替换为 Lucide SVG
|
||||||
- 选择项持久化:素材 / 背景音乐 / 历史视频
|
- 交互按钮保持一致尺寸与对齐
|
||||||
- 列表内滚动定位选中项,避免页面跳动
|
|
||||||
- 刷新回顶部(首页 / 发布页)
|
|
||||||
- 背景音乐试听即选中并自动开启,音量滑块实时影响试听
|
|
||||||
|
|
||||||
### 涉及文件
|
### 涉及文件
|
||||||
- `frontend/src/app/page.tsx`
|
- `frontend/src/components/home/`
|
||||||
- `frontend/src/app/publish/page.tsx`
|
- `frontend/src/app/publish/page.tsx`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🎵 字体与背景音乐资源库接入 (15:50)
|
|
||||||
|
|
||||||
### 资源库
|
|
||||||
- `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 归一化,保证配音音量不被压低
|
|
||||||
|
|
||||||
### 关键修改
|
|
||||||
`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)
|
|
||||||
|
|
||||||
### 前端
|
|
||||||
- 样式选择 + 预览面板
|
|
||||||
- 字号可调(覆盖样式默认值)
|
|
||||||
- 字体文件动态加载
|
|
||||||
|
|
||||||
### Remotion
|
|
||||||
- 样式参数透传到 `Subtitles` / `Title`
|
|
||||||
- 渲染前临时复制字体到渲染目录
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📝 文档更新
|
## 📝 文档更新
|
||||||
|
|
||||||
- [x] `Docs/QWEN3_TTS_DEPLOY.md`: 添加 Flash Attention 安装指南
|
- [x] `Docs/QWEN3_TTS_DEPLOY.md`: 添加 Flash Attention 安装指南
|
||||||
- [x] `Docs/DEPLOY_MANUAL.md`: 添加 Watchdog 部署说明
|
- [x] `Docs/DEPLOY_MANUAL.md`: 添加 Watchdog 部署说明
|
||||||
- [x] `Docs/task_complete.md`: 更新进度至 100% (Day 16)
|
- [x] `Docs/task_complete.md`: 更新进度至 100% (Day 16)
|
||||||
- [x] `README.md`: 新增样式与背景音乐能力说明
|
|
||||||
- [x] `Docs/BACKEND_README.md`: 资产接口与混音链路说明
|
|
||||||
- [x] `Docs/FRONTEND_README.md`: 新增样式预览与BGM试听说明
|
|
||||||
|
|||||||
154
Docs/DevLogs/Day17.md
Normal file
154
Docs/DevLogs/Day17.md
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
---
|
||||||
|
|
||||||
|
## 🧩 前端 UI 拆分 (11:00)
|
||||||
|
|
||||||
|
### 内容
|
||||||
|
- 首页 `page.tsx` 拆分为独立 UI 组件,状态与逻辑仍集中在页面
|
||||||
|
- 新增首页组件目录 `frontend/src/components/home/`
|
||||||
|
|
||||||
|
### 组件列表
|
||||||
|
- `HomeHeader`
|
||||||
|
- `MaterialSelector`
|
||||||
|
- `ScriptEditor`
|
||||||
|
- `TitleSubtitlePanel`
|
||||||
|
- `VoiceSelector`
|
||||||
|
- `RefAudioPanel`
|
||||||
|
- `BgmPanel`
|
||||||
|
- `GenerateActionBar`
|
||||||
|
- `PreviewPanel`
|
||||||
|
- `HistoryList`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧰 前端通用工具抽取 (11:30)
|
||||||
|
|
||||||
|
### 内容
|
||||||
|
- 抽取 API Base / 资源 URL / 日期格式化等通用工具
|
||||||
|
- 首页与发布页统一调用,消除重复逻辑
|
||||||
|
|
||||||
|
### 涉及文件
|
||||||
|
- `frontend/src/lib/media.ts`
|
||||||
|
- `frontend/src/app/page.tsx`
|
||||||
|
- `frontend/src/app/publish/page.tsx`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 前端规范更新 (11:40)
|
||||||
|
|
||||||
|
### 内容
|
||||||
|
- 更新 `FRONTEND_DEV.md` 以匹配最新目录结构
|
||||||
|
- 新增 `media.ts` 使用规范与示例
|
||||||
|
- 增加组件拆分规范与页面 checklist
|
||||||
|
|
||||||
|
### 涉及文件
|
||||||
|
- `Docs/FRONTEND_DEV.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎨 交互体验与视图优化 (12:00)
|
||||||
|
|
||||||
|
### 主页优化
|
||||||
|
- 最新生成作品优先选中并预览
|
||||||
|
- 选择项持久化:素材 / 背景音乐 / 历史作品
|
||||||
|
- 列表内滚动定位选中项,避免页面跳动
|
||||||
|
- 刷新回到顶部(首页)
|
||||||
|
- 标题/字幕样式预览面板
|
||||||
|
- 背景音乐试听即选中,音量滑块实时生效
|
||||||
|
- 标题/字幕预览按素材分辨率缩放,字号更接近成片
|
||||||
|
- 标题/字幕样式选择持久化,刷新不回默认
|
||||||
|
- 默认样式更新:标题 90px 站酷快乐体,字幕 60px 经典黄字 + DingTalkJinBuTi
|
||||||
|
|
||||||
|
### 发布页优化
|
||||||
|
- 选择作品改为卡片列表 + 搜索 + 刷新
|
||||||
|
- 预览改为弹窗模式
|
||||||
|
- 刷新回到顶部(发布页)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎵 背景音乐链路修复 (13:00)
|
||||||
|
|
||||||
|
### 修复点
|
||||||
|
- FFmpeg 混音改为 `shell=False`,避免 `filter_complex` 被 shell 误解析
|
||||||
|
- `amix` 禁用归一化,避免配音音量被压低
|
||||||
|
|
||||||
|
### 关键修改
|
||||||
|
`backend/app/services/video_service.py`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚡ 性能微优化 (14:30)
|
||||||
|
|
||||||
|
### 内容
|
||||||
|
- 列表渲染启用 `content-visibility`(素材/历史/参考音频/发布作品),BGM 列表保留滚动定位
|
||||||
|
- 首屏数据请求并行化(`Promise.allSettled`)
|
||||||
|
- localStorage 写入防抖(文本/标题/BGM 音量/发布表单)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🗣️ 字幕断句修复 (13:30)
|
||||||
|
|
||||||
|
### 内容
|
||||||
|
- 字幕切分逻辑保留英文单词整体,避免中英混合被硬切
|
||||||
|
|
||||||
|
### 涉及文件
|
||||||
|
- `backend/app/services/whisper_service.py`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧱 资源库与样式能力接入 (14:00)
|
||||||
|
|
||||||
|
### 内容
|
||||||
|
- 字体库 / 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`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🖼️ 预览弹窗增强 (15:00)
|
||||||
|
|
||||||
|
### 内容
|
||||||
|
- 预览弹窗统一为可复用组件,支持标题与提示
|
||||||
|
- 发布页预览与素材预览共享弹窗样式
|
||||||
|
- 弹窗头部样式统一(图标 + 标题 + 关闭按钮)
|
||||||
|
|
||||||
|
### 涉及文件
|
||||||
|
- `frontend/src/components/VideoPreviewModal.tsx`
|
||||||
|
- `frontend/src/app/page.tsx`
|
||||||
|
- `frontend/src/app/publish/page.tsx`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧭 术语统一 (15:20)
|
||||||
|
|
||||||
|
### 内容
|
||||||
|
- “视频预览” → “作品预览”
|
||||||
|
- “历史视频” → “历史作品”
|
||||||
|
- “选择要发布的视频” → “选择要发布的作品”
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛠️ 运维调整 (15:40)
|
||||||
|
|
||||||
|
### 内容
|
||||||
|
- Watchdog 移除 LatentSync 监控,避免长推理误杀
|
||||||
|
- LatentSync PM2 增加内存重启阈值(运行时配置)
|
||||||
|
|
||||||
|
---
|
||||||
@@ -24,11 +24,12 @@
|
|||||||
| :---: | :--- | :--- |
|
| :---: | :--- | :--- |
|
||||||
| 🔥 **High** | `Docs/DevLogs/DayN.md` | **(最新日志)** 详细记录变更、修复、代码片段 |
|
| 🔥 **High** | `Docs/DevLogs/DayN.md` | **(最新日志)** 详细记录变更、修复、代码片段 |
|
||||||
| 🔥 **High** | `Docs/task_complete.md` | **(任务总览)** 更新 `[x]`、进度条、时间线 |
|
| 🔥 **High** | `Docs/task_complete.md` | **(任务总览)** 更新 `[x]`、进度条、时间线 |
|
||||||
| ⚡ **Med** | `README.md` | **(项目主页)** 功能特性、技术栈、最新截图 |
|
| ⚡ **Med** | `README.md` | **(项目主页)** 功能特性、技术栈、最新截图 |
|
||||||
| ⚡ **Med** | `Docs/DEPLOY_MANUAL.md` | **(部署手册)** 环境变量、依赖包、启动命令变更 |
|
| ⚡ **Med** | `Docs/DEPLOY_MANUAL.md` | **(部署手册)** 环境变量、依赖包、启动命令变更 |
|
||||||
| ⚡ **Med** | `Docs/FRONTEND_DEV.md` | **(前端规范)** API封装、日期格式化、新页面规范 |
|
| ⚡ **Med** | `Docs/FRONTEND_DEV.md` | **(前端规范)** API封装、日期格式化、新页面规范 |
|
||||||
| 🧊 **Low** | `Docs/implementation_plan.md` | **(实施计划)** 核对计划与实际实现的差异 |
|
| ⚡ **Med** | `Docs/FRONTEND_README.md` | **(前端文档)** 功能说明、页面变更 |
|
||||||
| 🧊 **Low** | `frontend/README.md` | **(前端文档)** 新页面路由、组件用法、UI变更 |
|
| 🧊 **Low** | `Docs/implementation_plan.md` | **(实施计划)** 核对计划与实际实现的差异 |
|
||||||
|
| 🧊 **Low** | `Docs/architecture_plan.md` | **(前端架构)** 拆分计划与阶段目标 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -140,20 +141,20 @@
|
|||||||
|
|
||||||
> **核心原则**:使用正确的工具,避免字符编码问题
|
> **核心原则**:使用正确的工具,避免字符编码问题
|
||||||
|
|
||||||
### ✅ 推荐工具:replace_file_content
|
### ✅ 推荐工具:apply_patch
|
||||||
|
|
||||||
**使用场景**:
|
**使用场景**:
|
||||||
- 追加新章节到文件末尾
|
- 追加新章节到文件末尾
|
||||||
- 修改/替换现有章节内容
|
- 修改/替换现有章节内容
|
||||||
- 更新状态标记(🔄 → ✅)
|
- 更新状态标记(🔄 → ✅)
|
||||||
- 修正错误内容
|
- 修正错误内容
|
||||||
|
|
||||||
**优势**:
|
**优势**:
|
||||||
- ✅ 自动处理字符编码(Windows CRLF)
|
- ✅ 自动处理字符编码(Windows CRLF)
|
||||||
- ✅ 精确替换,不会误删其他内容
|
- ✅ 精确替换,不会误删其他内容
|
||||||
- ✅ 有错误提示,方便调试
|
- ✅ 有错误提示,方便调试
|
||||||
|
|
||||||
**注意事项**:
|
**注意事项**:
|
||||||
```markdown
|
```markdown
|
||||||
1. **必须精确匹配**:TargetContent 必须与文件完全一致
|
1. **必须精确匹配**:TargetContent 必须与文件完全一致
|
||||||
2. **处理换行符**:文件使用 \r\n,不要漏掉 \r
|
2. **处理换行符**:文件使用 \r\n,不要漏掉 \r
|
||||||
@@ -177,39 +178,45 @@
|
|||||||
|
|
||||||
### 📝 最佳实践示例
|
### 📝 最佳实践示例
|
||||||
|
|
||||||
**追加新章节**:
|
**追加新章节**:
|
||||||
```python
|
```diff
|
||||||
replace_file_content(
|
*** Begin Patch
|
||||||
TargetFile="path/to/DayN.md",
|
*** Update File: Docs/DevLogs/DayN.md
|
||||||
TargetContent="## 🔗 相关文档\n\n...\n\n", # 文件末尾的内容
|
@@
|
||||||
ReplacementContent="## 🔗 相关文档\n\n...\n\n---\n\n## 🆕 新章节\n内容...",
|
## 🔗 相关文档
|
||||||
StartLine=280,
|
|
||||||
EndLine=284
|
...
|
||||||
)
|
---
|
||||||
```
|
|
||||||
|
## 🆕 新章节
|
||||||
|
内容...
|
||||||
|
*** End Patch
|
||||||
|
```
|
||||||
|
|
||||||
**修改现有内容**:
|
**修改现有内容**:
|
||||||
```python
|
```diff
|
||||||
replace_file_content(
|
*** Begin Patch
|
||||||
TargetContent="**状态**:🔄 待修复",
|
*** Update File: Docs/DevLogs/DayN.md
|
||||||
ReplacementContent="**状态**:✅ 已修复",
|
@@
|
||||||
StartLine=310,
|
-**状态**:🔄 待修复
|
||||||
EndLine=310
|
+**状态**:✅ 已修复
|
||||||
)
|
*** End Patch
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📁 文件结构
|
## 📁 文件结构
|
||||||
|
|
||||||
```
|
```
|
||||||
ViGent/Docs/
|
ViGent2/Docs/
|
||||||
├── task_complete.md # 任务总览(仅按需更新)
|
├── task_complete.md # 任务总览(仅按需更新)
|
||||||
├── Doc_Rules.md # 本文件
|
├── Doc_Rules.md # 本文件
|
||||||
├── FRONTEND_DEV.md # 前端开发规范
|
├── FRONTEND_DEV.md # 前端开发规范
|
||||||
├── DEPLOY_MANUAL.md # 部署手册
|
├── FRONTEND_README.md # 前端功能文档
|
||||||
├── SUPABASE_DEPLOY.md # Supabase 部署文档
|
├── architecture_plan.md # 前端拆分计划
|
||||||
|
├── DEPLOY_MANUAL.md # 部署手册
|
||||||
|
├── SUPABASE_DEPLOY.md # Supabase 部署文档
|
||||||
└── DevLogs/
|
└── DevLogs/
|
||||||
├── Day1.md # 开发日志
|
├── Day1.md # 开发日志
|
||||||
└── ...
|
└── ...
|
||||||
@@ -305,4 +312,4 @@ ViGent/Docs/
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**最后更新**:2026-01-23
|
**最后更新**:2026-02-04
|
||||||
|
|||||||
@@ -10,9 +10,13 @@ frontend/src/
|
|||||||
│ ├── admin/ # 管理员页面
|
│ ├── admin/ # 管理员页面
|
||||||
│ ├── login/ # 登录页面
|
│ ├── login/ # 登录页面
|
||||||
│ └── register/ # 注册页面
|
│ └── register/ # 注册页面
|
||||||
|
├── components/ # 可复用组件
|
||||||
|
│ ├── home/ # 首页拆分组件
|
||||||
|
│ └── ...
|
||||||
├── lib/ # 公共工具函数
|
├── lib/ # 公共工具函数
|
||||||
│ ├── axios.ts # Axios 实例(含 401/403 拦截器)
|
│ ├── axios.ts # Axios 实例(含 401/403 拦截器)
|
||||||
│ └── auth.ts # 认证相关函数
|
│ ├── auth.ts # 认证相关函数
|
||||||
|
│ └── media.ts # API Base / URL / 日期等通用工具
|
||||||
└── proxy.ts # 路由代理(原 middleware)
|
└── proxy.ts # 路由代理(原 middleware)
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -146,6 +150,26 @@ 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()`(自动编码中文路径)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 日期格式化规范
|
## 日期格式化规范
|
||||||
|
|
||||||
### 禁止使用 `toLocaleString()`
|
### 禁止使用 `toLocaleString()`
|
||||||
@@ -161,25 +185,46 @@ new Date(timestamp * 1000).toLocaleString('zh-CN')
|
|||||||
**正确做法:**
|
**正确做法:**
|
||||||
```typescript
|
```typescript
|
||||||
// ✅ 使用固定格式
|
// ✅ 使用固定格式
|
||||||
const formatDate = (timestamp: number) => {
|
import { formatDate } from '@/lib/media';
|
||||||
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}`;
|
|
||||||
};
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 组件拆分规范
|
||||||
|
|
||||||
|
当页面组件超过 300-500 行,建议拆分到 `components/`:
|
||||||
|
|
||||||
|
- `page.tsx` 负责状态与业务逻辑
|
||||||
|
- 组件只接受 props 与回调,尽量不直接发 API
|
||||||
|
- 首页拆分组件统一放在 `components/home/`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 用户偏好持久化
|
||||||
|
|
||||||
|
首页涉及样式与字号等用户偏好时,需持久化并在刷新后恢复:
|
||||||
|
|
||||||
|
- **必须持久化**:
|
||||||
|
- 标题样式 ID / 字幕样式 ID
|
||||||
|
- 标题字号 / 字幕字号
|
||||||
|
- 背景音乐选择 / 音量 / 开关状态
|
||||||
|
- 素材选择 / 历史作品选择
|
||||||
|
|
||||||
|
### 实施规范
|
||||||
|
- 使用 `storageKey = userId || 'guest'`,按用户隔离。
|
||||||
|
- **恢复先于保存**:恢复完成前禁止写入(`isRestored` 保护)。
|
||||||
|
- 避免默认值覆盖用户选择(优先读取已保存值)。
|
||||||
|
- 如需新增持久化字段,必须加入恢复与保存逻辑,并更新本节。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 新增页面 Checklist
|
## 新增页面 Checklist
|
||||||
|
|
||||||
1. [ ] 导入 `import api from '@/lib/axios'`
|
1. [ ] 导入 `import api from '@/lib/axios'`
|
||||||
2. [ ] 所有 API 请求使用 `api.get/post/delete()` 而非原生 `fetch`
|
2. [ ] 所有 API 请求使用 `api.get/post/delete()` 而非原生 `fetch`
|
||||||
3. [ ] 日期格式化使用固定格式函数,不用 `toLocaleString()`
|
3. [ ] 日期格式化使用 `@/lib/media` 的 `formatDate`
|
||||||
4. [ ] 添加 `'use client'` 指令(如需客户端交互)
|
4. [ ] 资源 URL 使用 `resolveMediaUrl`/`resolveAssetUrl`
|
||||||
|
5. [ ] 添加 `'use client'` 指令(如需客户端交互)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# ViGent2 Frontend
|
# ViGent2 Frontend
|
||||||
|
|
||||||
ViGent2 的前端界面,采用 Next.js 14 + TailwindCSS 构建。
|
ViGent2 的前端界面,采用 Next.js 16 + TailwindCSS 构建。
|
||||||
|
|
||||||
## ✨ 核心功能
|
## ✨ 核心功能
|
||||||
|
|
||||||
@@ -11,8 +11,9 @@ ViGent2 的前端界面,采用 Next.js 14 + TailwindCSS 构建。
|
|||||||
- **标题/字幕样式**: 样式选择 + 预览 + 字号调节 (Day 16)。
|
- **标题/字幕样式**: 样式选择 + 预览 + 字号调节 (Day 16)。
|
||||||
- **背景音乐**: 试听 + 音量控制 + 选择持久化 (Day 16)。
|
- **背景音乐**: 试听 + 音量控制 + 选择持久化 (Day 16)。
|
||||||
- **交互优化**: 选择项持久化、列表内定位、刷新回顶部 (Day 16)。
|
- **交互优化**: 选择项持久化、列表内定位、刷新回顶部 (Day 16)。
|
||||||
|
- **预览一致性**: 标题/字幕预览按素材分辨率缩放,效果更接近成片 (Day 17)。
|
||||||
- **进度追踪**: 实时显示视频生成进度 (10% -> 100%)。
|
- **进度追踪**: 实时显示视频生成进度 (10% -> 100%)。
|
||||||
- **结果预览**: 生成完成后直接播放下载。
|
- **作品预览**: 生成完成后直接播放下载(作品预览 + 历史作品)。
|
||||||
- **本地保存**: 文案/标题自动保存,刷新后恢复 (Day 14)。
|
- **本地保存**: 文案/标题自动保存,刷新后恢复 (Day 14)。
|
||||||
|
|
||||||
### 2. 全自动发布 (`/publish`) [Day 7 新增]
|
### 2. 全自动发布 (`/publish`) [Day 7 新增]
|
||||||
@@ -22,6 +23,7 @@ ViGent2 的前端界面,采用 Next.js 14 + TailwindCSS 构建。
|
|||||||
- 实时检测扫码状态 (Wait/Success)。
|
- 实时检测扫码状态 (Wait/Success)。
|
||||||
- Cookie 自动保存与状态同步。
|
- Cookie 自动保存与状态同步。
|
||||||
- **发布配置**: 设置视频标题、标签、简介。
|
- **发布配置**: 设置视频标题、标签、简介。
|
||||||
|
- **作品选择**: 卡片列表 + 搜索 + 预览弹窗。
|
||||||
- **定时任务**: 支持 "立即发布" 或 "定时发布"。
|
- **定时任务**: 支持 "立即发布" 或 "定时发布"。
|
||||||
|
|
||||||
### 3. 声音克隆 [Day 13 新增]
|
### 3. 声音克隆 [Day 13 新增]
|
||||||
@@ -34,6 +36,8 @@ ViGent2 的前端界面,采用 Next.js 14 + TailwindCSS 构建。
|
|||||||
- **逐字高亮字幕**: 卡拉OK效果,默认开启,可关闭。
|
- **逐字高亮字幕**: 卡拉OK效果,默认开启,可关闭。
|
||||||
- **自动对齐**: 基于 faster-whisper 生成字级别时间戳。
|
- **自动对齐**: 基于 faster-whisper 生成字级别时间戳。
|
||||||
- **样式预设**: 标题/字幕样式选择 + 预览 + 字号调节 (Day 16)。
|
- **样式预设**: 标题/字幕样式选择 + 预览 + 字号调节 (Day 16)。
|
||||||
|
- **默认样式**: 标题 90px 站酷快乐体;字幕 60px 经典黄字 + DingTalkJinBuTi (Day 17)。
|
||||||
|
- **样式持久化**: 标题/字幕样式与字号刷新保留 (Day 17)。
|
||||||
|
|
||||||
### 5. 背景音乐 [Day 16 新增]
|
### 5. 背景音乐 [Day 16 新增]
|
||||||
- **试听预览**: 点击试听即选中,音量滑块实时生效。
|
- **试听预览**: 点击试听即选中,音量滑块实时生效。
|
||||||
@@ -52,7 +56,7 @@ ViGent2 的前端界面,采用 Next.js 14 + TailwindCSS 构建。
|
|||||||
|
|
||||||
## 🛠️ 技术栈
|
## 🛠️ 技术栈
|
||||||
|
|
||||||
- **框架**: Next.js 14 (App Router)
|
- **框架**: Next.js 16 (App Router)
|
||||||
- **样式**: TailwindCSS
|
- **样式**: TailwindCSS
|
||||||
- **图标**: Lucide React
|
- **图标**: Lucide React
|
||||||
- **组件**: 自定义现代化组件 (Glassmorphism 风格)
|
- **组件**: 自定义现代化组件 (Glassmorphism 风格)
|
||||||
@@ -85,15 +89,16 @@ src/
|
|||||||
│ │ └── page.tsx
|
│ │ └── page.tsx
|
||||||
│ └── layout.tsx # 全局布局 (导航栏)
|
│ └── layout.tsx # 全局布局 (导航栏)
|
||||||
├── components/ # UI 组件
|
├── components/ # UI 组件
|
||||||
│ ├── VideoUploader.tsx # 视频上传
|
│ ├── home/ # 首页拆分组件
|
||||||
│ ├── StatusBadge.tsx # 状态徽章
|
|
||||||
│ └── ...
|
│ └── ...
|
||||||
└── lib/ # 工具函数
|
└── 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。
|
- **代理配置**: Next.js Rewrites (如需) 或直接 CORS。
|
||||||
|
|
||||||
## 🎨 设计规范
|
## 🎨 设计规范
|
||||||
|
|||||||
@@ -42,17 +42,28 @@
|
|||||||
|
|
||||||
| 模块 | 技术选择 | 备选方案 |
|
| 模块 | 技术选择 | 备选方案 |
|
||||||
|------|----------|----------|
|
|------|----------|----------|
|
||||||
| **前端框架** | Next.js 14 | Vue 3 + Vite |
|
| **前端框架** | Next.js 16 | Vue 3 + Vite |
|
||||||
| **UI 组件库** | Tailwind + shadcn/ui | Ant Design |
|
| **UI 组件库** | TailwindCSS (自定义组件) | Ant Design |
|
||||||
| **后端框架** | FastAPI | Flask |
|
| **后端框架** | FastAPI | Flask |
|
||||||
| **任务队列** | Celery + Redis | RQ / Dramatiq |
|
| **任务队列** | FastAPI BackgroundTasks (asyncio) | Celery + Redis |
|
||||||
| **唇形同步** | **LatentSync 1.6** | MuseTalk / Wav2Lip |
|
| **唇形同步** | **LatentSync 1.6** | MuseTalk / Wav2Lip |
|
||||||
| **TTS 配音** | EdgeTTS | CosyVoice |
|
| **TTS 配音** | EdgeTTS | CosyVoice |
|
||||||
| **声音克隆** | **Qwen3-TTS 1.7B** ✅ | GPT-SoVITS |
|
| **声音克隆** | **Qwen3-TTS 1.7B** ✅ | GPT-SoVITS |
|
||||||
| **视频处理** | FFmpeg | MoviePy |
|
| **视频处理** | FFmpeg | MoviePy |
|
||||||
| **自动发布** | social-auto-upload | 自行实现 |
|
| **自动发布** | Playwright | 自行实现 |
|
||||||
| **数据库** | SQLite → PostgreSQL | MySQL |
|
| **数据库** | Supabase (PostgreSQL) | MySQL |
|
||||||
| **文件存储** | 本地 / MinIO | 阿里云 OSS |
|
| **文件存储** | 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)
|
### 阶段一:核心功能验证 (MVP)
|
||||||
|
|
||||||
> **目标**:验证 MuseTalk + EdgeTTS 效果,跑通端到端流程
|
> **目标**:验证 LatentSync + EdgeTTS 效果,跑通端到端流程
|
||||||
|
|
||||||
#### 1.1 环境搭建
|
#### 1.1 环境搭建
|
||||||
|
|
||||||
```bash
|
参考 `models/LatentSync/DEPLOY.md` 完成 LatentSync 环境与权重部署。
|
||||||
# 创建项目目录
|
|
||||||
mkdir TalkingHeadAgent
|
|
||||||
cd TalkingHeadAgent
|
|
||||||
|
|
||||||
# 克隆 MuseTalk
|
|
||||||
git clone https://github.com/TMElyralab/MuseTalk.git
|
|
||||||
|
|
||||||
# 安装依赖
|
|
||||||
cd MuseTalk
|
|
||||||
pip install -r requirements.txt
|
|
||||||
|
|
||||||
# 下载模型权重 (按官方文档)
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 1.2 集成 EdgeTTS
|
#### 1.2 集成 EdgeTTS
|
||||||
|
|
||||||
@@ -98,13 +96,13 @@ async def text_to_speech(text: str, voice: str = "zh-CN-YunxiNeural", output_pat
|
|||||||
# test_pipeline.py
|
# test_pipeline.py
|
||||||
"""
|
"""
|
||||||
1. 文案 → EdgeTTS → 音频
|
1. 文案 → EdgeTTS → 音频
|
||||||
2. 静态视频 + 音频 → MuseTalk → 口播视频
|
2. 静态视频 + 音频 → LatentSync → 口播视频
|
||||||
3. 添加字幕 → FFmpeg → 最终视频
|
3. 添加字幕 → FFmpeg → 最终视频
|
||||||
"""
|
"""
|
||||||
```
|
```
|
||||||
|
|
||||||
#### 1.4 验证标准
|
#### 1.4 验证标准
|
||||||
- [ ] MuseTalk 能正常推理
|
- [ ] LatentSync 能正常推理
|
||||||
- [ ] 唇形与音频同步率 > 90%
|
- [ ] 唇形与音频同步率 > 90%
|
||||||
- [ ] 单个视频生成时间 < 2 分钟
|
- [ ] 单个视频生成时间 < 2 分钟
|
||||||
|
|
||||||
@@ -145,22 +143,16 @@ backend/
|
|||||||
| `/api/materials` | POST | 上传素材视频 | ✅ |
|
| `/api/materials` | POST | 上传素材视频 | ✅ |
|
||||||
| `/api/materials` | GET | 获取素材列表 | ✅ |
|
| `/api/materials` | GET | 获取素材列表 | ✅ |
|
||||||
| `/api/videos/generate` | POST | 创建视频生成任务 | ✅ |
|
| `/api/videos/generate` | POST | 创建视频生成任务 | ✅ |
|
||||||
| `/api/tasks/{id}` | GET | 查询任务状态 | ✅ |
|
| `/api/videos/tasks/{id}` | GET | 查询任务状态 | ✅ |
|
||||||
| `/api/videos/{id}/download` | GET | 下载生成的视频 | ✅ |
|
| `/api/videos/generated` | GET | 获取历史作品列表 | ✅ |
|
||||||
| `/api/publish` | POST | 发布到社交平台 | ✅ |
|
| `/api/publish` | POST | 发布到社交平台 | ✅ |
|
||||||
|
|
||||||
#### 2.3 Celery 任务定义
|
#### 2.3 BackgroundTasks 任务定义
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# tasks/celery_tasks.py
|
# app/api/videos.py
|
||||||
@celery.task
|
background_tasks.add_task(_process_video_generation, task_id, req, user_id)
|
||||||
def generate_video_task(material_id: str, text: str, voice: str):
|
```
|
||||||
# 1. TTS 生成音频
|
|
||||||
# 2. MuseTalk 唇形同步
|
|
||||||
# 3. FFmpeg 添加字幕
|
|
||||||
# 4. 保存并返回视频 URL
|
|
||||||
pass
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -183,9 +175,9 @@ def generate_video_task(material_id: str, text: str, voice: str):
|
|||||||
# 创建 Next.js 项目
|
# 创建 Next.js 项目
|
||||||
npx create-next-app@latest frontend --typescript --tailwind --app
|
npx create-next-app@latest frontend --typescript --tailwind --app
|
||||||
|
|
||||||
# 安装依赖
|
# 安装依赖
|
||||||
cd frontend
|
cd frontend
|
||||||
npm install @tanstack/react-query axios
|
npm install axios swr
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
# ViGent2 开发任务清单 (Task Log)
|
# ViGent2 开发任务清单 (Task Log)
|
||||||
|
|
||||||
**项目**: ViGent2 数字人口播视频生成系统
|
**项目**: ViGent2 数字人口播视频生成系统
|
||||||
**进度**: 100% (Day 16 - 深度优化完成)
|
**进度**: 100% (Day 17 - 前端重构与体验优化)
|
||||||
**更新时间**: 2026-02-03
|
**更新时间**: 2026-02-04
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -10,15 +10,23 @@
|
|||||||
|
|
||||||
> 这里记录了每一天的核心开发内容与 milestone。
|
> 这里记录了每一天的核心开发内容与 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 混音稳定性与字幕断句优化。
|
||||||
|
|
||||||
|
### Day 16: 深度性能优化
|
||||||
- [x] **Qwen-TTS 加速**: 集成 Flash Attention 2,模型加载速度提升至 8.9s。
|
- [x] **Qwen-TTS 加速**: 集成 Flash Attention 2,模型加载速度提升至 8.9s。
|
||||||
- [x] **服务守护**: 开发 `Watchdog` 看门狗机制,自动监控并重启僵死服务。
|
- [x] **服务守护**: 开发 `Watchdog` 看门狗机制,自动监控并重启僵死服务。
|
||||||
- [x] **LatentSync 性能确认**: 验证 DeepCache + 原生 Flash Attn 生效。
|
- [x] **LatentSync 性能确认**: 验证 DeepCache + 原生 Flash Attn 生效。
|
||||||
- [x] **文档重构**: 全面更新 README、部署手册及后端文档。
|
- [x] **文档重构**: 全面更新 README、部署手册及后端文档。
|
||||||
- [x] **UI 交互优化**: 选择项持久化、列表内定位、刷新回顶部。
|
|
||||||
- [x] **样式与预览**: 标题/字幕样式选择 + 预览 + 字号调节。
|
|
||||||
- [x] **背景音乐**: 试听 + 音量控制 + 混音稳定性修复。
|
|
||||||
- [x] **资产库接入**: 字体/BGM 资源库 + `/api/assets` 资源接口。
|
|
||||||
|
|
||||||
### Day 15: 手机号认证迁移
|
### Day 15: 手机号认证迁移
|
||||||
- [x] **认证系统升级**: 从邮箱迁移至 11 位手机号注册/登录。
|
- [x] **认证系统升级**: 从邮箱迁移至 11 位手机号注册/登录。
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
- 🎙️ **多模态配音** - 支持 **EdgeTTS** (微软超自然语音) 和 **Qwen3-TTS** (3秒极速声音克隆)。
|
- 🎙️ **多模态配音** - 支持 **EdgeTTS** (微软超自然语音) 和 **Qwen3-TTS** (3秒极速声音克隆)。
|
||||||
- 📝 **智能字幕** - 集成 faster-whisper + Remotion,自动生成逐字高亮 (卡拉OK效果) 字幕。
|
- 📝 **智能字幕** - 集成 faster-whisper + Remotion,自动生成逐字高亮 (卡拉OK效果) 字幕。
|
||||||
- 🎨 **样式预设** - 标题/字幕样式选择 + 预览 + 字号调节,支持自定义字体库。
|
- 🎨 **样式预设** - 标题/字幕样式选择 + 预览 + 字号调节,支持自定义字体库。
|
||||||
|
- 🖼️ **作品预览一致性** - 标题/字幕预览按素材分辨率缩放,效果更接近成片。
|
||||||
- 🎵 **背景音乐** - 试听 + 音量控制 + 混音,保持配音音量稳定。
|
- 🎵 **背景音乐** - 试听 + 音量控制 + 混音,保持配音音量稳定。
|
||||||
- 🤖 **AI 辅助创作** - 内置 GLM-4.7-Flash,支持 B站/抖音链接文案提取、AI 洗稿、标题/标签自动生成。
|
- 🤖 **AI 辅助创作** - 内置 GLM-4.7-Flash,支持 B站/抖音链接文案提取、AI 洗稿、标题/标签自动生成。
|
||||||
|
|
||||||
@@ -35,7 +36,7 @@
|
|||||||
|
|
||||||
| 领域 | 核心技术 | 说明 |
|
| 领域 | 核心技术 | 说明 |
|
||||||
|------|----------|------|
|
|------|----------|------|
|
||||||
| **前端** | Next.js 14 | TypeScript, TailwindCSS, SWR |
|
| **前端** | Next.js 16 | TypeScript, TailwindCSS, SWR |
|
||||||
| **后端** | FastAPI | Python 3.10, AsyncIO, PM2 |
|
| **后端** | FastAPI | Python 3.10, AsyncIO, PM2 |
|
||||||
| **数据库** | Supabase | PostgreSQL, Storage (本地/S3), Auth |
|
| **数据库** | Supabase | PostgreSQL, Storage (本地/S3), Auth |
|
||||||
| **唇形同步** | LatentSync 1.6 | PyTorch 2.5, Diffusers, DeepCache |
|
| **唇形同步** | LatentSync 1.6 | PyTorch 2.5, Diffusers, DeepCache |
|
||||||
|
|||||||
@@ -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)
|
|
||||||
- **响应式**: 适配桌面端大屏操作
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect, useMemo } from "react";
|
||||||
import useSWR from 'swr';
|
import useSWR from 'swr';
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import api from "@/lib/axios";
|
import api from "@/lib/axios";
|
||||||
|
import { getApiBaseUrl, formatDate } from "@/lib/media";
|
||||||
import { useAuth } from "@/contexts/AuthContext";
|
import { useAuth } from "@/contexts/AuthContext";
|
||||||
import AccountSettingsDropdown from "@/components/AccountSettingsDropdown";
|
import AccountSettingsDropdown from "@/components/AccountSettingsDropdown";
|
||||||
|
import VideoPreviewModal from "@/components/VideoPreviewModal";
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
@@ -13,26 +15,16 @@ import {
|
|||||||
QrCode,
|
QrCode,
|
||||||
Rocket,
|
Rocket,
|
||||||
Clock,
|
Clock,
|
||||||
|
RefreshCw,
|
||||||
|
Search,
|
||||||
|
Eye,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
// SWR fetcher 使用 axios(自动处理 401/403)
|
// SWR fetcher 使用 axios(自动处理 401/403)
|
||||||
const fetcher = (url: string) => api.get(url).then((res) => res.data);
|
const fetcher = (url: string) => api.get(url).then((res) => res.data);
|
||||||
|
|
||||||
// 动态获取 API 地址:服务端使用 localhost,客户端使用当前域名
|
// 动态获取 API 地址:服务端使用 localhost,客户端使用当前域名
|
||||||
const API_BASE = typeof window === 'undefined'
|
const API_BASE = getApiBaseUrl();
|
||||||
? '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}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
interface Account {
|
interface Account {
|
||||||
platform: string;
|
platform: string;
|
||||||
@@ -47,9 +39,11 @@ interface Video {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function PublishPage() {
|
export default function PublishPage() {
|
||||||
const [accounts, setAccounts] = useState<Account[]>([]);
|
const [accounts, setAccounts] = useState<Account[]>([]);
|
||||||
const [videos, setVideos] = useState<Video[]>([]);
|
const [videos, setVideos] = useState<Video[]>([]);
|
||||||
const [selectedVideo, setSelectedVideo] = useState<string>("");
|
const [selectedVideo, setSelectedVideo] = useState<string>("");
|
||||||
|
const [videoFilter, setVideoFilter] = useState<string>("");
|
||||||
|
const [previewVideoUrl, setPreviewVideoUrl] = useState<string | null>(null);
|
||||||
const [selectedPlatforms, setSelectedPlatforms] = useState<string[]>([]);
|
const [selectedPlatforms, setSelectedPlatforms] = useState<string[]>([]);
|
||||||
const [title, setTitle] = useState<string>("");
|
const [title, setTitle] = useState<string>("");
|
||||||
const [tags, setTags] = useState<string>("");
|
const [tags, setTags] = useState<string>("");
|
||||||
@@ -68,8 +62,10 @@ export default function PublishPage() {
|
|||||||
|
|
||||||
// 加载账号和视频列表
|
// 加载账号和视频列表
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchAccounts();
|
void Promise.allSettled([
|
||||||
fetchVideos();
|
fetchAccounts(),
|
||||||
|
fetchVideos(),
|
||||||
|
]);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -116,13 +112,21 @@ export default function PublishPage() {
|
|||||||
}, [storageKey, isAuthLoading]);
|
}, [storageKey, isAuthLoading]);
|
||||||
|
|
||||||
// 保存用户输入到 localStorage(恢复完成后才保存,未登录用户也可保存)
|
// 保存用户输入到 localStorage(恢复完成后才保存,未登录用户也可保存)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isRestored) localStorage.setItem(`vigent_${storageKey}_publish_title`, title);
|
if (!isRestored) return;
|
||||||
}, [title, storageKey, isRestored]);
|
const timeout = setTimeout(() => {
|
||||||
|
localStorage.setItem(`vigent_${storageKey}_publish_title`, title);
|
||||||
|
}, 300);
|
||||||
|
return () => clearTimeout(timeout);
|
||||||
|
}, [title, storageKey, isRestored]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isRestored) localStorage.setItem(`vigent_${storageKey}_publish_tags`, tags);
|
if (!isRestored) return;
|
||||||
}, [tags, storageKey, isRestored]);
|
const timeout = setTimeout(() => {
|
||||||
|
localStorage.setItem(`vigent_${storageKey}_publish_tags`, tags);
|
||||||
|
}, 300);
|
||||||
|
return () => clearTimeout(timeout);
|
||||||
|
}, [tags, storageKey, isRestored]);
|
||||||
|
|
||||||
const fetchAccounts = async () => {
|
const fetchAccounts = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -270,17 +274,28 @@ export default function PublishPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const platformIcons: Record<string, string> = {
|
const platformIcons: Record<string, string> = {
|
||||||
douyin: "🎵",
|
douyin: "🎵",
|
||||||
xiaohongshu: "📕",
|
xiaohongshu: "📕",
|
||||||
weixin: "💬",
|
weixin: "💬",
|
||||||
kuaishou: "⚡",
|
kuaishou: "⚡",
|
||||||
bilibili: "📺",
|
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 (
|
return (
|
||||||
<div className="min-h-dvh">
|
<div className="min-h-dvh">
|
||||||
{/* QR码弹窗 */}
|
<VideoPreviewModal
|
||||||
|
onClose={() => setPreviewVideoUrl(null)}
|
||||||
|
videoUrl={previewVideoUrl}
|
||||||
|
title="发布视频预览"
|
||||||
|
/>
|
||||||
|
{/* QR码弹窗 */}
|
||||||
{qrPlatform && (
|
{qrPlatform && (
|
||||||
<div className="fixed inset-0 bg-black/80 flex items-center justify-center z-50">
|
<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]">
|
<div className="bg-white rounded-2xl p-8 max-w-md min-w-[320px]">
|
||||||
@@ -404,33 +419,87 @@ export default function PublishPage() {
|
|||||||
|
|
||||||
{/* 右侧: 发布表单 */}
|
{/* 右侧: 发布表单 */}
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* 选择视频 */}
|
{/* 选择视频 */}
|
||||||
<div className="bg-white/5 rounded-2xl p-6 border border-white/10 backdrop-blur-sm">
|
<div className="bg-white/5 rounded-2xl p-6 border border-white/10 backdrop-blur-sm">
|
||||||
<h2 className="text-lg font-semibold text-white mb-4">
|
<h2 className="text-lg font-semibold text-white mb-4">
|
||||||
🎥 选择要发布的视频
|
🎥 选择要发布的作品
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
{videos.length === 0 ? (
|
{videos.length === 0 ? (
|
||||||
<p className="text-gray-400">
|
<p className="text-gray-400">
|
||||||
暂无已生成的视频,请先
|
暂无已生成的视频,请先
|
||||||
<Link href="/" className="text-purple-400 hover:underline">
|
<Link href="/" className="text-purple-400 hover:underline">
|
||||||
生成视频
|
生成视频
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<select
|
<>
|
||||||
value={selectedVideo}
|
<div className="flex items-center gap-2 mb-3">
|
||||||
onChange={(e) => setSelectedVideo(e.target.value)}
|
<div className="relative flex-1">
|
||||||
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"
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-500" />
|
||||||
>
|
<input
|
||||||
{videos.map((v) => (
|
value={videoFilter}
|
||||||
<option key={v.path} value={v.path}>
|
onChange={(e) => setVideoFilter(e.target.value)}
|
||||||
{v.name}
|
placeholder="搜索视频..."
|
||||||
</option>
|
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"
|
||||||
))}
|
/>
|
||||||
</select>
|
</div>
|
||||||
)}
|
<button
|
||||||
</div>
|
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();
|
||||||
|
setPreviewVideoUrl(v.path);
|
||||||
|
}}
|
||||||
|
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">
|
<div className="bg-white/5 rounded-2xl p-6 border border-white/10 backdrop-blur-sm">
|
||||||
|
|||||||
@@ -1,13 +1,21 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
|
import { X, Video } from "lucide-react";
|
||||||
|
|
||||||
interface VideoPreviewModalProps {
|
interface VideoPreviewModalProps {
|
||||||
videoUrl: string | null;
|
videoUrl: string | null;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
title?: string;
|
||||||
|
subtitle?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function VideoPreviewModal({ videoUrl, onClose }: VideoPreviewModalProps) {
|
export default function VideoPreviewModal({
|
||||||
|
videoUrl,
|
||||||
|
onClose,
|
||||||
|
title = "视频预览",
|
||||||
|
subtitle = "ESC 关闭 · 点击空白关闭",
|
||||||
|
}: VideoPreviewModalProps) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// 按 ESC 关闭
|
// 按 ESC 关闭
|
||||||
const handleEsc = (e: KeyboardEvent) => {
|
const handleEsc = (e: KeyboardEvent) => {
|
||||||
@@ -27,24 +35,36 @@ export default function VideoPreviewModal({ videoUrl, onClose }: VideoPreviewMod
|
|||||||
if (!videoUrl) return null;
|
if (!videoUrl) return null;
|
||||||
|
|
||||||
return (
|
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
|
||||||
<div className="relative w-full max-w-4xl bg-gray-900 border border-white/10 rounded-2xl shadow-2xl overflow-hidden flex flex-col">
|
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"
|
||||||
{/* Header */}
|
onClick={onClose}
|
||||||
<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">
|
<div
|
||||||
🎥 视频预览
|
className="relative w-full max-w-4xl bg-gray-900 border border-white/10 rounded-2xl shadow-2xl overflow-hidden flex flex-col"
|
||||||
</h3>
|
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
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="p-2 text-gray-400 hover:text-white hover:bg-white/10 rounded-lg transition-colors"
|
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">
|
<X className="h-5 w-5" />
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Video Player */}
|
|
||||||
<div className="bg-black flex items-center justify-center min-h-[50vh] max-h-[80vh]">
|
<div className="bg-black flex items-center justify-center min-h-[50vh] max-h-[80vh]">
|
||||||
<video
|
<video
|
||||||
src={videoUrl}
|
src={videoUrl}
|
||||||
@@ -53,12 +73,7 @@ export default function VideoPreviewModal({ videoUrl, onClose }: VideoPreviewMod
|
|||||||
className="w-full h-full max-h-[80vh] object-contain"
|
className="w-full h-full max-h-[80vh] object-contain"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Click outside to close */}
|
|
||||||
<div className="absolute inset-0 -z-10" onClick={onClose}></div>
|
|
||||||
</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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
262
frontend/src/components/home/RefAudioPanel.tsx
Normal file
262
frontend/src/components/home/RefAudioPanel.tsx
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
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) {
|
||||||
|
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={URL.createObjectURL(recordedBlob)} 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
309
frontend/src/components/home/TitleSubtitlePanel.tsx
Normal file
309
frontend/src/components/home/TitleSubtitlePanel.tsx
Normal file
@@ -0,0 +1,309 @@
|
|||||||
|
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;
|
||||||
|
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,
|
||||||
|
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">片头标题(可选)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={videoTitle}
|
||||||
|
onChange={(e) => onTitleChange(e.target.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>
|
||||||
|
);
|
||||||
|
}
|
||||||
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}`;
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user