Compare commits

...

6 Commits

Author SHA1 Message Date
Kevin Wong
31469ca01d 更新 2026-02-04 16:56:16 +08:00
Kevin Wong
22ea3dd0db 更新 2026-02-04 16:54:59 +08:00
Kevin Wong
8a5912c517 更新 2026-02-04 15:59:45 +08:00
Kevin Wong
74516dbcdb 更新 2026-02-04 11:56:37 +08:00
Kevin Wong
5357d97012 更新 2026-02-04 11:41:55 +08:00
Kevin Wong
33d8e52802 更新 2026-02-03 17:42:04 +08:00
34 changed files with 3312 additions and 1959 deletions

View File

@@ -1,5 +1,3 @@
---
## 🔧 Qwen-TTS Flash Attention 优化 (10:00)
### 优化背景
@@ -18,8 +16,6 @@ pip install -U flash-attn --no-build-isolation
- **显存占用**: 显著降低,消除 OOM 风险
- **代码变动**: 无代码变动,仅环境优化 (自动检测)
---
## 🛡️ 服务看门狗 Watchdog (10:30)
### 问题描述
@@ -53,59 +49,86 @@ if service["failures"] >= service['threshold']:
---
## 🎨 UI 交互体验优化 (15:30)
## 🎨 交互体验与视图优化 (14:20)
### 优化内容
### 主页优化
- 视频生成完成后,预览优先选中最新输出
- 选择项持久化:素材 / 背景音乐 / 历史视频
- 选择项持久化:素材 / 背景音乐 / 历史作品
- 列表内滚动定位选中项,避免页面跳动
- 刷新回顶部(首页 / 发布页
- 刷新回顶部(首页)
- 标题/字幕样式预览面板
- 背景音乐试听即选中并自动开启,音量滑块实时影响试听
### 涉及文件
- `frontend/src/app/page.tsx`
- `frontend/src/app/publish/page.tsx`
### 发布页优化
- 刷新回到顶部(发布页)
---
## 🎵 字体与背景音乐资源库接入 (15:50)
## 🎵 背景音乐链路修复 (15:00)
### 资源库
- `backend/assets/fonts/`SuperIPAgent 字体全量导入)
- `backend/assets/bgm/`(背景音乐素材)
- `backend/assets/styles/{subtitle.json,title.json}`(样式预设)
### 服务能力
- `/api/assets/subtitle-styles``/api/assets/title-styles``/api/assets/bgm`
- `/assets` 静态挂载供前端预览与试听
### 生成链路调整
- 先完成人声与唇形/字幕对齐,再混入 BGM
- 修复 FFmpeg shell 解析导致的混音失败
- 禁用 amix 归一化,保证配音音量不被压低
### 修复点
- FFmpeg 混音改为 `shell=False`,避免 `filter_complex` 被 shell 误解析
- `amix` 禁用归一化,避免配音音量被压低
### 关键修改
`backend/app/services/video_service.py`
```python
filter_complex = (
"[0:a]volume=1.0[a0];"
f"[1:a]volume={volume}[a1];"
"[a0][a1]amix=inputs=2:duration=first:dropout_transition=2:normalize=0[aout]"
)
```
---
## 🖼 标题/字幕样式预览 (16:10)
## 🗣 字幕断句修复 (15:20)
### 前端
- 样式选择 + 预览面板
- 字号可调(覆盖样式默认值)
- 字体文件动态加载
### 内容
- 字幕切分逻辑保留英文单词整体,避免中英混合被硬切
### Remotion
- 样式参数透传到 `Subtitles` / `Title`
- 渲染前临时复制字体到渲染目录
### 涉及文件
- `backend/app/services/whisper_service.py`
---
## 🧱 资源库与样式能力接入 (15:40)
### 内容
- 字体库 / BGM 资源接入本地 assets
- 新增样式配置文件(字幕/标题)
- 新增资源 API 与静态挂载 `/assets`
- Remotion 支持样式参数与字体加载
### 涉及文件
- `backend/assets/fonts/`
- `backend/assets/bgm/`
- `backend/assets/styles/subtitle.json`
- `backend/assets/styles/title.json`
- `backend/app/services/assets_service.py`
- `backend/app/api/assets.py`
- `backend/app/main.py`
- `backend/app/api/videos.py`
- `backend/app/services/remotion_service.py`
- `remotion/src/components/Subtitles.tsx`
- `remotion/src/components/Title.tsx`
- `remotion/src/Video.tsx`
- `remotion/render.ts`
- `frontend/src/app/page.tsx`
- `frontend/next.config.ts`
---
## 🛠️ 运维调整 (16:10)
### 内容
- Watchdog 移除 LatentSync 监控,避免长推理误杀
- LatentSync PM2 增加内存重启阈值(运行时配置)
---
## 🎯 前端按钮图标统一 (16:40)
### 内容
- 首页与发布页按钮图标统一替换为 Lucide SVG
- 交互按钮保持一致尺寸与对齐
### 涉及文件
- `frontend/src/components/home/`
- `frontend/src/app/publish/page.tsx`
---
@@ -114,6 +137,3 @@ filter_complex = (
- [x] `Docs/QWEN3_TTS_DEPLOY.md`: 添加 Flash Attention 安装指南
- [x] `Docs/DEPLOY_MANUAL.md`: 添加 Watchdog 部署说明
- [x] `Docs/task_complete.md`: 更新进度至 100% (Day 16)
- [x] `README.md`: 新增样式与背景音乐能力说明
- [x] `Docs/BACKEND_README.md`: 资产接口与混音链路说明
- [x] `Docs/FRONTEND_README.md`: 新增样式预览与BGM试听说明

155
Docs/DevLogs/Day17.md Normal file
View File

@@ -0,0 +1,155 @@
# Day 17 - 前端重构与体验优化
## 🧩 前端 UI 拆分 (09:10)
### 内容
- 首页 `page.tsx` 拆分为独立 UI 组件,状态与逻辑仍集中在页面
- 新增首页组件目录 `frontend/src/components/home/`
### 组件列表
- `HomeHeader`
- `MaterialSelector`
- `ScriptEditor`
- `TitleSubtitlePanel`
- `VoiceSelector`
- `RefAudioPanel`
- `BgmPanel`
- `GenerateActionBar`
- `PreviewPanel`
- `HistoryList`
---
## 🧰 前端通用工具抽取 (09:30)
### 内容
- 抽取 API Base / 资源 URL / 日期格式化等通用工具
- 首页与发布页统一调用,消除重复逻辑
### 涉及文件
- `frontend/src/lib/media.ts`
- `frontend/src/app/page.tsx`
- `frontend/src/app/publish/page.tsx`
---
## 📝 前端规范更新 (09:40)
### 内容
- 更新 `FRONTEND_DEV.md` 以匹配最新目录结构
- 新增 `media.ts` 使用规范与示例
- 增加组件拆分规范与页面 checklist
### 涉及文件
- `Docs/FRONTEND_DEV.md`
---
## 🎨 交互体验与视图优化 (10:00)
### 标题/字幕预览
- 标题/字幕预览按素材分辨率缩放,字号更接近成片
- 标题/字幕样式选择持久化,刷新不回默认
- 默认样式更新:标题 90px 站酷快乐体,字幕 60px 经典黄字 + DingTalkJinBuTi
### 发布页优化
- 选择作品改为卡片列表 + 搜索 + 预览弹窗
---
## ⚡ 性能微优化 (10:30)
### 内容
- 列表渲染启用 `content-visibility`(素材/历史/参考音频/发布作品BGM 列表保留滚动定位
- 首屏数据请求并行化(`Promise.allSettled`
- localStorage 写入防抖(文本/标题/BGM 音量/发布表单)
---
## 🖼️ 预览弹窗增强 (11:10)
### 内容
- 预览弹窗统一为可复用组件,支持标题与提示
- 发布页预览与素材预览共享弹窗样式
- 弹窗头部样式统一(图标 + 标题 + 关闭按钮)
### 涉及文件
- `frontend/src/components/VideoPreviewModal.tsx`
- `frontend/src/app/page.tsx`
- `frontend/src/app/publish/page.tsx`
---
## 🧭 术语统一 (11:20)
### 内容
- “视频预览” → “作品预览”
- “历史视频” → “历史作品”
- “选择要发布的视频” → “选择要发布的作品”
- “选择素材视频” → “视频素材”
- “选择配音方式” → “配音方式”
---
## 🧱 Phase 2 Hook 抽取 (11:45)
### 内容
- `useTitleSubtitleStyles`:标题/字幕样式获取与默认选择逻辑
- `useMaterials`:素材列表/上传/删除逻辑抽取
- `useRefAudios`:参考音频列表/上传/删除逻辑抽取
- `useBgm`:背景音乐列表与加载状态抽取
- `useMediaPlayers`:音频试听逻辑集中管理(参考音频/背景音乐)
- `useGeneratedVideos`:历史作品列表获取 + 选择逻辑抽取
### 涉及文件
- `frontend/src/hooks/useTitleSubtitleStyles.ts`
- `frontend/src/hooks/useMaterials.ts`
- `frontend/src/hooks/useRefAudios.ts`
- `frontend/src/hooks/useBgm.ts`
- `frontend/src/hooks/useMediaPlayers.ts`
- `frontend/src/hooks/useGeneratedVideos.ts`
- `frontend/src/app/page.tsx`
---
## 🧩 首页持久化修复 (12:20)
### 内容
- 接入 `useHomePersistence`,补齐 `isRestored` 恢复/保存逻辑
- 修复首页刷新后选择项恢复链路,`npm run build` 通过
### 涉及文件
- `frontend/src/app/page.tsx`
- `frontend/src/hooks/useHomePersistence.ts`
---
## 🧩 发布预览与播放修复 (14:10)
### 内容
- 发布页作品预览兼容签名 URL 与相对路径
- 参考音频试听统一走 `resolveMediaUrl`
- 素材/BGM 选择在列表变化时自动回退有效项
- 录音预览 URL 回收、预览弹窗滚动状态恢复、全局任务提示挂载
### 涉及文件
- `frontend/src/app/publish/page.tsx`
- `frontend/src/hooks/useMediaPlayers.ts`
- `frontend/src/hooks/useBgm.ts`
- `frontend/src/hooks/useMaterials.ts`
- `frontend/src/components/home/RefAudioPanel.tsx`
- `frontend/src/components/VideoPreviewModal.tsx`
- `frontend/src/app/layout.tsx`
---
## 🧩 标题同步与长度限制 (15:30)
### 内容
- 片头标题修改同步写入发布信息标题
- 标题输入兼容中文输入法,限制 15 字(发布信息同规则)
### 涉及文件
- `frontend/src/app/page.tsx`
- `frontend/src/components/home/TitleSubtitlePanel.tsx`
- `frontend/src/app/publish/page.tsx`

View File

@@ -24,11 +24,12 @@
| :---: | :--- | :--- |
| 🔥 **High** | `Docs/DevLogs/DayN.md` | **(最新日志)** 详细记录变更、修复、代码片段 |
| 🔥 **High** | `Docs/task_complete.md` | **(任务总览)** 更新 `[x]`、进度条、时间线 |
| ⚡ **Med** | `README.md` | **(项目主页)** 功能特性、技术栈、最新截图 |
| ⚡ **Med** | `Docs/DEPLOY_MANUAL.md` | **(部署手册)** 环境变量、依赖包、启动命令变更 |
| ⚡ **Med** | `Docs/FRONTEND_DEV.md` | **(前端规范)** API封装、日期格式化、新页面规范 |
| 🧊 **Low** | `Docs/implementation_plan.md` | **(实施计划)** 核对计划与实际实现的差异 |
| 🧊 **Low** | `frontend/README.md` | **(前端文档)** 新页面路由、组件用法、UI变更 |
| ⚡ **Med** | `README.md` | **(项目主页)** 功能特性、技术栈、最新截图 |
| ⚡ **Med** | `Docs/DEPLOY_MANUAL.md` | **(部署手册)** 环境变量、依赖包、启动命令变更 |
| ⚡ **Med** | `Docs/FRONTEND_DEV.md` | **(前端规范)** API封装、日期格式化、新页面规范 |
| **Med** | `Docs/FRONTEND_README.md` | **(前端文档)** 功能说明、页面变更 |
| 🧊 **Low** | `Docs/implementation_plan.md` | **(实施计划)** 核对计划与实际实现的差异 |
| 🧊 **Low** | `Docs/architecture_plan.md` | **(前端架构)** 拆分计划与阶段目标 |
---
@@ -140,20 +141,20 @@
> **核心原则**:使用正确的工具,避免字符编码问题
### ✅ 推荐工具:replace_file_content
### ✅ 推荐工具:apply_patch
**使用场景**
**使用场景**
- 追加新章节到文件末尾
- 修改/替换现有章节内容
- 更新状态标记(🔄 → ✅)
- 修正错误内容
**优势**
**优势**
- ✅ 自动处理字符编码Windows CRLF
- ✅ 精确替换,不会误删其他内容
- ✅ 有错误提示,方便调试
**注意事项**
**注意事项**
```markdown
1. **必须精确匹配**TargetContent 必须与文件完全一致
2. **处理换行符**:文件使用 \r\n不要漏掉 \r
@@ -177,39 +178,45 @@
### 📝 最佳实践示例
**追加新章节**
```python
replace_file_content(
TargetFile="path/to/DayN.md",
TargetContent="## 🔗 相关文档\n\n...\n\n", # 文件末尾的内容
ReplacementContent="## 🔗 相关文档\n\n...\n\n---\n\n## 🆕 新章节\n内容...",
StartLine=280,
EndLine=284
)
```
**追加新章节**
```diff
*** Begin Patch
*** Update File: Docs/DevLogs/DayN.md
@@
## 🔗 相关文档
...
---
## 🆕 新章节
内容...
*** End Patch
```
**修改现有内容**
```python
replace_file_content(
TargetContent="**状态**:🔄 待修复",
ReplacementContent="**状态**:✅ 已修复",
StartLine=310,
EndLine=310
)
```
**修改现有内容**
```diff
*** Begin Patch
*** Update File: Docs/DevLogs/DayN.md
@@
-**状态**:🔄 待修复
+**状态**:✅ 已修复
*** End Patch
```
---
## 📁 文件结构
## 📁 文件结构
```
ViGent/Docs/
├── task_complete.md # 任务总览(仅按需更新)
├── Doc_Rules.md # 本文件
├── FRONTEND_DEV.md # 前端开发规范
├── DEPLOY_MANUAL.md # 部署手册
├── SUPABASE_DEPLOY.md # Supabase 部署文档
ViGent2/Docs/
├── task_complete.md # 任务总览(仅按需更新)
├── Doc_Rules.md # 本文件
├── FRONTEND_DEV.md # 前端开发规范
├── FRONTEND_README.md # 前端功能文档
├── architecture_plan.md # 前端拆分计划
├── DEPLOY_MANUAL.md # 部署手册
├── SUPABASE_DEPLOY.md # Supabase 部署文档
└── DevLogs/
├── Day1.md # 开发日志
└── ...
@@ -217,7 +224,7 @@ ViGent/Docs/
---
## 📅 DayN.md 更新规则(日常更新)
## 📅 DayN.md 更新规则(日常更新)
### 新建判断 (对话开始前)
1. **回顾进度**:查看 `task_complete.md` 了解当前状态
@@ -225,9 +232,9 @@ ViGent/Docs/
- **今天 (与当前日期相同)** → 🚨 **绝对禁止创建新文件**,必须**追加**到现有 `DayN.md` 末尾!即使是完全不同的功能模块。
- **之前 (昨天或更早)** → 创建 `Day{N+1}.md`
### 追加格式
```markdown
---
### 追加格式
```markdown
---
## 🔧 [章节标题]
@@ -243,14 +250,18 @@ ViGent/Docs/
- ✅ 修复了 xxx
```
### 快速修复格式
```markdown
## 🐛 [Bug 简述] (HH:MM)
### 快速修复格式
```markdown
## 🐛 [Bug 简述] (HH:MM)
**问题**:一句话描述
**修复**:修改了 `文件名` 中的 xxx
**状态**:✅ 已修复 / 🔄 待验证
```
**状态**:✅ 已修复 / 🔄 待验证
```
### ⚠️ 注意
- **DayN.md 文件开头禁止使用 `---`**,避免被解析为 Front Matter。
- 分隔线只用于章节之间,不作为文件第一行。
---
@@ -305,4 +316,4 @@ ViGent/Docs/
---
**最后更新**2026-01-23
**最后更新**2026-02-04

View File

@@ -10,9 +10,13 @@ frontend/src/
│ ├── admin/ # 管理员页面
│ ├── login/ # 登录页面
│ └── register/ # 注册页面
├── components/ # 可复用组件
│ ├── home/ # 首页拆分组件
│ └── ...
├── lib/ # 公共工具函数
│ ├── axios.ts # Axios 实例(含 401/403 拦截器)
── auth.ts # 认证相关函数
── auth.ts # 认证相关函数
│ └── media.ts # API Base / URL / 日期等通用工具
└── proxy.ts # 路由代理(原 middleware
```
@@ -146,6 +150,27 @@ const { data } = useSWR('/api/xxx', fetcher, { refreshInterval: 2000 });
---
## 通用工具函数 (media.ts)
### 统一 API Base / URL 解析
使用 `@/lib/media` 统一处理服务端/客户端 API Base 与资源地址,避免硬编码:
```typescript
import { getApiBaseUrl, resolveMediaUrl, resolveAssetUrl, formatDate } from '@/lib/media';
const apiBase = getApiBaseUrl(); // SSR: http://localhost:8006 / Client: ''
const playableUrl = resolveMediaUrl(video.path); // 兼容签名 URL 与相对路径
const fontUrl = resolveAssetUrl(`fonts/${fontFile}`);
const timeText = formatDate(video.created_at);
```
### 资源路径规则
- 视频/音频:优先用 `resolveMediaUrl()`
- 字体/BGM使用 `resolveAssetUrl()`(自动编码中文路径)
- 预览前若已有签名 URL先用 `isAbsoluteUrl()` 判定,避免再次拼接
---
## 日期格式化规范
### 禁止使用 `toLocaleString()`
@@ -161,25 +186,56 @@ new Date(timestamp * 1000).toLocaleString('zh-CN')
**正确做法:**
```typescript
// ✅ 使用固定格式
const formatDate = (timestamp: number) => {
const d = new Date(timestamp * 1000);
const year = d.getFullYear();
const month = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
const hour = String(d.getHours()).padStart(2, '0');
const minute = String(d.getMinutes()).padStart(2, '0');
return `${year}/${month}/${day} ${hour}:${minute}`;
};
import { formatDate } from '@/lib/media';
```
---
## 组件拆分规范
当页面组件超过 300-500 行,建议拆分到 `components/`
- `page.tsx` 负责状态与业务逻辑
- 组件只接受 props 与回调,尽量不直接发 API
- 首页拆分组件统一放在 `components/home/`
---
## 用户偏好持久化
首页涉及样式与字号等用户偏好时,需持久化并在刷新后恢复:
- **必须持久化**
- 标题样式 ID / 字幕样式 ID
- 标题字号 / 字幕字号
- 背景音乐选择 / 音量 / 开关状态
- 素材选择 / 历史作品选择
### 实施规范
- 使用 `storageKey = userId || 'guest'`,按用户隔离。
- **恢复先于保存**:恢复完成前禁止写入(`isRestored` 保护)。
- 避免默认值覆盖用户选择(优先读取已保存值)。
- 优先使用 `useHomePersistence` 集中管理恢复/保存,页面内避免分散的 localStorage 读写。
- 如需新增持久化字段,必须加入恢复与保存逻辑,并更新本节。
---
## 标题输入规则
- 片头标题与发布信息标题统一限制 15 字。
- 中文输入法合成阶段不截断,合成结束后才校验长度。
- 首页片头标题修改会同步写入 `vigent_${storageKey}_publish_title`
- 避免使用 `maxLength` 强制截断输入法合成态。
---
## 新增页面 Checklist
1. [ ] 导入 `import api from '@/lib/axios'`
2. [ ] 所有 API 请求使用 `api.get/post/delete()` 而非原生 `fetch`
3. [ ] 日期格式化使用固定格式函数,不用 `toLocaleString()`
4. [ ] 添加 `'use client'` 指令(如需客户端交互)
3. [ ] 日期格式化使用 `@/lib/media``formatDate`
4. [ ] 资源 URL 使用 `resolveMediaUrl`/`resolveAssetUrl`
5. [ ] 添加 `'use client'` 指令(如需客户端交互)
---

View File

@@ -1,6 +1,6 @@
# ViGent2 Frontend
ViGent2 的前端界面,采用 Next.js 14 + TailwindCSS 构建。
ViGent2 的前端界面,采用 Next.js 16 + TailwindCSS 构建。
## ✨ 核心功能
@@ -11,9 +11,10 @@ ViGent2 的前端界面,采用 Next.js 14 + TailwindCSS 构建。
- **标题/字幕样式**: 样式选择 + 预览 + 字号调节 (Day 16)。
- **背景音乐**: 试听 + 音量控制 + 选择持久化 (Day 16)。
- **交互优化**: 选择项持久化、列表内定位、刷新回顶部 (Day 16)。
- **预览一致性**: 标题/字幕预览按素材分辨率缩放,效果更接近成片 (Day 17)。
- **进度追踪**: 实时显示视频生成进度 (10% -> 100%)。
- **结果预览**: 生成完成后直接播放下载。
- **本地保存**: 文案/标题自动保存,刷新后恢复 (Day 14)。
- **作品预览**: 生成完成后直接播放下载(作品预览 + 历史作品)
- **本地保存**: 文案/标题/偏好由 `useHomePersistence` 统一持久化,刷新后恢复 (Day 14/17)。
### 2. 全自动发布 (`/publish`) [Day 7 新增]
- **多平台管理**: 统一管理 B站、抖音、小红书账号状态。
@@ -22,6 +23,8 @@ ViGent2 的前端界面,采用 Next.js 14 + TailwindCSS 构建。
- 实时检测扫码状态 (Wait/Success)。
- Cookie 自动保存与状态同步。
- **发布配置**: 设置视频标题、标签、简介。
- **作品选择**: 卡片列表 + 搜索 + 预览弹窗。
- **预览兼容**: 签名 URL / 相对路径均可直接预览。
- **定时任务**: 支持 "立即发布" 或 "定时发布"。
### 3. 声音克隆 [Day 13 新增]
@@ -30,10 +33,13 @@ ViGent2 的前端界面,采用 Next.js 14 + TailwindCSS 构建。
- **一键克隆**: 选择参考音频后自动调用 Qwen3-TTS 服务。
### 4. 字幕与标题 [Day 13 新增]
- **片头标题**: 可选输入,视频开头显示 3 秒淡入淡出标题。
- **片头标题**: 可选输入,限制 15 字,视频开头显示 3 秒淡入淡出标题。
- **标题同步**: 首页片头标题修改会同步到发布信息标题。
- **逐字高亮字幕**: 卡拉OK效果默认开启可关闭。
- **自动对齐**: 基于 faster-whisper 生成字级别时间戳。
- **样式预设**: 标题/字幕样式选择 + 预览 + 字号调节 (Day 16)。
- **默认样式**: 标题 90px 站酷快乐体;字幕 60px 经典黄字 + DingTalkJinBuTi (Day 17)。
- **样式持久化**: 标题/字幕样式与字号刷新保留 (Day 17)。
### 5. 背景音乐 [Day 16 新增]
- **试听预览**: 点击试听即选中,音量滑块实时生效。
@@ -52,7 +58,7 @@ ViGent2 的前端界面,采用 Next.js 14 + TailwindCSS 构建。
## 🛠️ 技术栈
- **框架**: Next.js 14 (App Router)
- **框架**: Next.js 16 (App Router)
- **样式**: TailwindCSS
- **图标**: Lucide React
- **组件**: 自定义现代化组件 (Glassmorphism 风格)
@@ -85,15 +91,16 @@ src/
│ │ └── page.tsx
│ └── layout.tsx # 全局布局 (导航栏)
├── components/ # UI 组件
│ ├── VideoUploader.tsx # 视频上传
│ ├── StatusBadge.tsx # 状态徽章
│ ├── home/ # 首页拆分组件
│ └── ...
└── lib/ # 工具函数
└── media.ts # API Base / URL / 日期等通用工具
```
## 🔌 后端对接
- **Base URL**: `http://localhost:8006`
- **Base URL**: `http://localhost:8006` (SSR) / 相对路径 (Client)
- **URL 统一工具**: `@/lib/media` 提供 `resolveMediaUrl` / `resolveAssetUrl`
- **代理配置**: Next.js Rewrites (如需) 或直接 CORS。
## 🎨 设计规范

View File

@@ -42,17 +42,28 @@
| 模块 | 技术选择 | 备选方案 |
|------|----------|----------|
| **前端框架** | Next.js 14 | Vue 3 + Vite |
| **UI 组件库** | Tailwind + shadcn/ui | Ant Design |
| **后端框架** | FastAPI | Flask |
| **任务队列** | Celery + Redis | RQ / Dramatiq |
| **唇形同步** | **LatentSync 1.6** | MuseTalk / Wav2Lip |
| **TTS 配音** | EdgeTTS | CosyVoice |
| **声音克隆** | **Qwen3-TTS 1.7B** ✅ | GPT-SoVITS |
| **视频处理** | FFmpeg | MoviePy |
| **自动发布** | social-auto-upload | 自行实现 |
| **数据库** | SQLite → PostgreSQL | MySQL |
| **文件存储** | 本地 / MinIO | 阿里云 OSS |
| **前端框架** | Next.js 16 | Vue 3 + Vite |
| **UI 组件库** | TailwindCSS (自定义组件) | Ant Design |
| **后端框架** | FastAPI | Flask |
| **任务队列** | FastAPI BackgroundTasks (asyncio) | Celery + Redis |
| **唇形同步** | **LatentSync 1.6** | MuseTalk / Wav2Lip |
| **TTS 配音** | EdgeTTS | CosyVoice |
| **声音克隆** | **Qwen3-TTS 1.7B** ✅ | GPT-SoVITS |
| **视频处理** | FFmpeg | MoviePy |
| **自动发布** | Playwright | 自行实现 |
| **数据库** | Supabase (PostgreSQL) | MySQL |
| **文件存储** | Supabase Storage | 阿里云 OSS |
> **修正 (18:10)**:当前实现采用 Next.js 16、FastAPI BackgroundTasks 与 Supabase Storage/Auth自动发布基于 Playwright。
---
## ✅ 现状补充 (Day 17)
- 前端已拆分为组件化结构(`components/home/`),主页面逻辑集中。
- 通用工具 `media.ts` 统一处理 API Base / 资源 URL / 日期格式化。
- 作品预览弹窗统一样式,并支持素材/发布预览复用。
- 标题/字幕预览按素材分辨率缩放,效果更接近成片。
---
@@ -60,24 +71,11 @@
### 阶段一:核心功能验证 (MVP)
> **目标**:验证 MuseTalk + EdgeTTS 效果,跑通端到端流程
> **目标**:验证 LatentSync + EdgeTTS 效果,跑通端到端流程
#### 1.1 环境搭建
```bash
# 创建项目目录
mkdir TalkingHeadAgent
cd TalkingHeadAgent
# 克隆 MuseTalk
git clone https://github.com/TMElyralab/MuseTalk.git
# 安装依赖
cd MuseTalk
pip install -r requirements.txt
# 下载模型权重 (按官方文档)
```
#### 1.1 环境搭建
参考 `models/LatentSync/DEPLOY.md` 完成 LatentSync 环境与权重部署。
#### 1.2 集成 EdgeTTS
@@ -98,13 +96,13 @@ async def text_to_speech(text: str, voice: str = "zh-CN-YunxiNeural", output_pat
# test_pipeline.py
"""
1. 文案 → EdgeTTS → 音频
2. 静态视频 + 音频 → MuseTalk → 口播视频
2. 静态视频 + 音频 → LatentSync → 口播视频
3. 添加字幕 → FFmpeg → 最终视频
"""
```
#### 1.4 验证标准
- [ ] MuseTalk 能正常推理
- [ ] LatentSync 能正常推理
- [ ] 唇形与音频同步率 > 90%
- [ ] 单个视频生成时间 < 2 分钟
@@ -142,25 +140,19 @@ backend/
| 端点 | 方法 | 功能 |
|------|------|------|
| `/api/materials` | POST | 上传素材视频 | ✅ |
| `/api/materials` | POST | 上传视频素材 | ✅ |
| `/api/materials` | GET | 获取素材列表 | ✅ |
| `/api/videos/generate` | POST | 创建视频生成任务 | ✅ |
| `/api/tasks/{id}` | GET | 查询任务状态 | ✅ |
| `/api/videos/{id}/download` | GET | 下载生成的视频 | ✅ |
| `/api/videos/tasks/{id}` | GET | 查询任务状态 | ✅ |
| `/api/videos/generated` | GET | 获取历史作品列表 | ✅ |
| `/api/publish` | POST | 发布到社交平台 | ✅ |
#### 2.3 Celery 任务定义
```python
# tasks/celery_tasks.py
@celery.task
def generate_video_task(material_id: str, text: str, voice: str):
# 1. TTS 生成音频
# 2. MuseTalk 唇形同步
# 3. FFmpeg 添加字幕
# 4. 保存并返回视频 URL
pass
```
#### 2.3 BackgroundTasks 任务定义
```python
# app/api/videos.py
background_tasks.add_task(_process_video_generation, task_id, req, user_id)
```
---
@@ -172,7 +164,7 @@ def generate_video_task(material_id: str, text: str, voice: str):
| 页面 | 功能 |
|------|------|
| **素材库** | 上传/管理多场景素材视频 |
| **素材库** | 上传/管理多场景视频素材 |
| **生成视频** | 输入文案、选择素材、生成预览 |
| **任务中心** | 查看生成进度、下载视频 |
| **发布管理** | 绑定平台、一键发布、定时发布 |
@@ -183,9 +175,9 @@ def generate_video_task(material_id: str, text: str, voice: str):
# 创建 Next.js 项目
npx create-next-app@latest frontend --typescript --tailwind --app
# 安装依赖
cd frontend
npm install @tanstack/react-query axios
# 安装依赖
cd frontend
npm install axios swr
```
---

View File

@@ -1,8 +1,8 @@
# ViGent2 开发任务清单 (Task Log)
**项目**: ViGent2 数字人口播视频生成系统
**进度**: 100% (Day 16 - 深度优化完成)
**更新时间**: 2026-02-03
**进度**: 100% (Day 17 - 前端重构与体验优化)
**更新时间**: 2026-02-04
---
@@ -10,15 +10,26 @@
> 这里记录了每一天的核心开发内容与 milestone。
### Day 16: 深度性能优化 (Current) 🚀
### Day 17: 前端重构与体验优化 (Current) 🚀
- [x] **UI 组件拆分**: 首页拆分为独立组件,降低 `page.tsx` 复杂度。
- [x] **通用工具抽取**: `media.ts` 统一 API Base / URL / 日期格式化。
- [x] **交互优化**: 选择项持久化、列表内定位、刷新回顶部、最新作品优先预览。
- [x] **发布页改造**: 作品列表卡片化 + 搜索 + 预览弹窗。
- [x] **预览体验**: 预览弹窗统一头部样式与提示文案。
- [x] **预览一致性**: 标题/字幕预览按素材分辨率缩放。
- [x] **样式默认与持久化**: 默认样式与字号调整,刷新保留用户选择。
- [x] **性能微优化**: 列表渲染优化 + 并行请求 + localStorage 防抖。
- [x] **资源能力**: 字体/BGM 资源库 + `/api/assets` 接入。
- [x] **音频与字幕修复**: BGM 混音稳定性与字幕断句优化。
- [x] **持久化修复**: 接入 `useHomePersistence`,恢复 `isRestored` 逻辑并通过构建。
- [x] **预览与选择修复**: 发布预览兼容签名 URL音频试听路径解析素材/BGM 回退有效项。
- [x] **体验细节优化**: 录音预览 URL 回收,预览弹窗滚动恢复,全局任务提示挂载。
### Day 16: 深度性能优化
- [x] **Qwen-TTS 加速**: 集成 Flash Attention 2模型加载速度提升至 8.9s。
- [x] **服务守护**: 开发 `Watchdog` 看门狗机制,自动监控并重启僵死服务。
- [x] **LatentSync 性能确认**: 验证 DeepCache + 原生 Flash Attn 生效。
- [x] **文档重构**: 全面更新 README、部署手册及后端文档。
- [x] **UI 交互优化**: 选择项持久化、列表内定位、刷新回顶部。
- [x] **样式与预览**: 标题/字幕样式选择 + 预览 + 字号调节。
- [x] **背景音乐**: 试听 + 音量控制 + 混音稳定性修复。
- [x] **资产库接入**: 字体/BGM 资源库 + `/api/assets` 资源接口。
### Day 15: 手机号认证迁移
- [x] **认证系统升级**: 从邮箱迁移至 11 位手机号注册/登录。

View File

@@ -20,14 +20,17 @@
- 🎙️ **多模态配音** - 支持 **EdgeTTS** (微软超自然语音) 和 **Qwen3-TTS** (3秒极速声音克隆)。
- 📝 **智能字幕** - 集成 faster-whisper + Remotion自动生成逐字高亮 (卡拉OK效果) 字幕。
- 🎨 **样式预设** - 标题/字幕样式选择 + 预览 + 字号调节,支持自定义字体库。
- 🖼️ **作品预览一致性** - 标题/字幕预览按素材分辨率缩放,效果更接近成片。
- 💾 **用户偏好持久化** - 首页状态统一恢复/保存,刷新后延续上次配置。
- 🎵 **背景音乐** - 试听 + 音量控制 + 混音,保持配音音量稳定。
- 🤖 **AI 辅助创作** - 内置 GLM-4.7-Flash支持 B站/抖音链接文案提取、AI 洗稿、标题/标签自动生成。
### 平台化功能
- 📱 **全自动发布** - 支持 B站、抖音、小红书定时发布扫码登录 + Cookie 持久化。
- 🔐 **企业级认证** - 完善的用户隔离系统 (Supabase),支持手机号注册/登录、密码管理
- 📱 **全自动发布** - 支持 B站、抖音、小红书定时发布扫码登录 + Cookie 持久化。
- 🖥️ **发布管理预览** - 支持签名 URL / 相对路径作品预览,确保可直接播放
- 🔐 **认证与隔离** - 基于 Supabase 的用户隔离,支持手机号注册/登录、密码管理。
- 🛡️ **服务守护** - 内置 Watchdog 看门狗机制,自动监控并重启僵死服务,确保 7x24h 稳定运行。
- 🚀 **极致性能** - 视频预压缩、模型常驻服务 (0s加载)、双 GPU 流水线并发。
- 🚀 **性能优化** - 视频预压缩、模型常驻服务(近实时加载、双 GPU 流水线并发。
---
@@ -35,7 +38,7 @@
| 领域 | 核心技术 | 说明 |
|------|----------|------|
| **前端** | Next.js 14 | TypeScript, TailwindCSS, SWR |
| **前端** | Next.js 16 | TypeScript, TailwindCSS, SWR |
| **后端** | FastAPI | Python 3.10, AsyncIO, PM2 |
| **数据库** | Supabase | PostgreSQL, Storage (本地/S3), Auth |
| **唇形同步** | LatentSync 1.6 | PyTorch 2.5, Diffusers, DeepCache |

View File

@@ -2,9 +2,9 @@
{
"id": "subtitle_classic_yellow",
"label": "经典黄字",
"font_file": "title/思源黑体/SourceHanSansCN-Bold思源黑体免费.otf",
"font_family": "SourceHanSansCN-Bold",
"font_size": 52,
"font_file": "DingTalk JinBuTi.ttf",
"font_family": "DingTalkJinBuTi",
"font_size": 60,
"highlight_color": "#FFE600",
"normal_color": "#FFFFFF",
"stroke_color": "#000000",

View File

@@ -1,4 +1,18 @@
[
{
"id": "title_pop",
"label": "站酷快乐体",
"font_file": "title/站酷快乐体.ttf",
"font_family": "ZCoolHappy",
"font_size": 90,
"color": "#FFFFFF",
"stroke_color": "#000000",
"stroke_size": 8,
"letter_spacing": 5,
"top_margin": 62,
"font_weight": 900,
"is_default": true
},
{
"id": "title_bold_white",
"label": "黑体大标题",
@@ -11,7 +25,7 @@
"letter_spacing": 4,
"top_margin": 60,
"font_weight": 900,
"is_default": true
"is_default": false
},
{
"id": "title_serif_gold",
@@ -40,19 +54,5 @@
"top_margin": 60,
"font_weight": 900,
"is_default": false
},
{
"id": "title_pop",
"label": "站酷快乐体",
"font_file": "title/站酷快乐体.ttf",
"font_family": "ZCoolHappy",
"font_size": 74,
"color": "#FFFFFF",
"stroke_color": "#000000",
"stroke_size": 8,
"letter_spacing": 5,
"top_margin": 62,
"font_weight": 900,
"is_default": false
}
]

View File

@@ -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)
- **响应式**: 适配桌面端大屏操作

View File

@@ -39,6 +39,7 @@ export default function RootLayout({
>
<AuthProvider>
<TaskProvider>
<GlobalTaskIndicator />
{children}
</TaskProvider>
</AuthProvider>

File diff suppressed because it is too large Load Diff

View File

@@ -1,30 +1,37 @@
"use client";
import { useState, useEffect } from "react";
import useSWR from 'swr';
import Link from "next/link";
import { useState, useEffect, useMemo, useRef } from "react";
import useSWR from 'swr';
import Link from "next/link";
import api from "@/lib/axios";
import { getApiBaseUrl, formatDate, resolveMediaUrl, isAbsoluteUrl } from "@/lib/media";
import { useAuth } from "@/contexts/AuthContext";
import AccountSettingsDropdown from "@/components/AccountSettingsDropdown";
import VideoPreviewModal from "@/components/VideoPreviewModal";
import {
ArrowLeft,
RotateCcw,
LogOut,
QrCode,
Rocket,
Clock,
RefreshCw,
Search,
Eye,
} from "lucide-react";
// SWR fetcher 使用 axios自动处理 401/403
const fetcher = (url: string) => api.get(url).then((res) => res.data);
// 动态获取 API 地址:服务端使用 localhost客户端使用当前域名
const API_BASE = typeof window === 'undefined'
? 'http://localhost:8006'
: '';
// 格式化日期(避免 Hydration 错误)
const formatDate = (timestamp: number) => {
const d = new Date(timestamp * 1000);
const year = d.getFullYear();
const month = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
const hour = String(d.getHours()).padStart(2, '0');
const minute = String(d.getMinutes()).padStart(2, '0');
return `${year}/${month}/${day} ${hour}:${minute}`;
};
// 动态获取 API 地址:服务端使用 localhost客户端使用当前域名
const API_BASE = getApiBaseUrl();
const TITLE_MAX_LENGTH = 15;
const clampTitle = (value: string) => value.slice(0, TITLE_MAX_LENGTH);
const applyTitleLimit = (prev: string, next: string) => {
if (next.length <= TITLE_MAX_LENGTH) return next;
if (prev.length >= TITLE_MAX_LENGTH) return prev;
return next.slice(0, TITLE_MAX_LENGTH);
};
interface Account {
platform: string;
@@ -38,13 +45,17 @@ interface Video {
path: string;
}
export default function PublishPage() {
const [accounts, setAccounts] = useState<Account[]>([]);
const [videos, setVideos] = useState<Video[]>([]);
const [selectedVideo, setSelectedVideo] = useState<string>("");
const [selectedPlatforms, setSelectedPlatforms] = useState<string[]>([]);
const [title, setTitle] = useState<string>("");
const [tags, setTags] = useState<string>("");
export default function PublishPage() {
const [accounts, setAccounts] = useState<Account[]>([]);
const [videos, setVideos] = useState<Video[]>([]);
const [selectedVideo, setSelectedVideo] = useState<string>("");
const [videoFilter, setVideoFilter] = useState<string>("");
const [previewVideoUrl, setPreviewVideoUrl] = useState<string | null>(null);
const [selectedPlatforms, setSelectedPlatforms] = useState<string[]>([]);
const [title, setTitle] = useState<string>("");
const [tags, setTags] = useState<string>("");
const isTitleComposingRef = useRef(false);
const titleCommittedRef = useRef("");
const [isPublishing, setIsPublishing] = useState(false);
const [publishResults, setPublishResults] = useState<any[]>([]);
const [scheduleMode, setScheduleMode] = useState<"now" | "scheduled">("now");
@@ -55,13 +66,25 @@ export default function PublishPage() {
// 使用全局认证状态
const { userId, isLoading: isAuthLoading } = useAuth();
// 是否已从 localStorage 恢复完成
const [isRestored, setIsRestored] = useState(false);
// 是否已从 localStorage 恢复完成
const [isRestored, setIsRestored] = useState(false);
useEffect(() => {
if (isTitleComposingRef.current) return;
titleCommittedRef.current = title;
}, [title]);
const commitTitle = (value: string) => {
titleCommittedRef.current = value;
setTitle(value);
};
// 加载账号和视频列表
useEffect(() => {
fetchAccounts();
fetchVideos();
void Promise.allSettled([
fetchAccounts(),
fetchVideos(),
]);
}, []);
useEffect(() => {
@@ -87,7 +110,7 @@ export default function PublishPage() {
console.log("[Publish] localStorage 数据:", { savedTitle, savedTags });
if (savedTitle) setTitle(savedTitle);
if (savedTitle) setTitle(clampTitle(savedTitle));
if (savedTags) {
// 兼容 JSON 数组格式AI 生成)和字符串格式(手动输入)
try {
@@ -108,13 +131,21 @@ export default function PublishPage() {
}, [storageKey, isAuthLoading]);
// 保存用户输入到 localStorage恢复完成后才保存未登录用户也可保存
useEffect(() => {
if (isRestored) localStorage.setItem(`vigent_${storageKey}_publish_title`, title);
}, [title, storageKey, isRestored]);
useEffect(() => {
if (!isRestored) return;
const timeout = setTimeout(() => {
localStorage.setItem(`vigent_${storageKey}_publish_title`, title);
}, 300);
return () => clearTimeout(timeout);
}, [title, storageKey, isRestored]);
useEffect(() => {
if (isRestored) localStorage.setItem(`vigent_${storageKey}_publish_tags`, tags);
}, [tags, storageKey, isRestored]);
useEffect(() => {
if (!isRestored) return;
const timeout = setTimeout(() => {
localStorage.setItem(`vigent_${storageKey}_publish_tags`, tags);
}, 300);
return () => clearTimeout(timeout);
}, [tags, storageKey, isRestored]);
const fetchAccounts = async () => {
try {
@@ -262,17 +293,28 @@ export default function PublishPage() {
}
};
const platformIcons: Record<string, string> = {
douyin: "🎵",
xiaohongshu: "📕",
weixin: "💬",
kuaishou: "⚡",
bilibili: "📺",
};
const platformIcons: Record<string, string> = {
douyin: "🎵",
xiaohongshu: "📕",
weixin: "💬",
kuaishou: "⚡",
bilibili: "📺",
};
const filteredVideos = useMemo(() => {
const query = videoFilter.trim().toLowerCase();
if (!query) return videos;
return videos.filter((v) => v.name.toLowerCase().includes(query));
}, [videos, videoFilter]);
return (
<div className="min-h-dvh">
{/* QR码弹窗 */}
return (
<div className="min-h-dvh">
<VideoPreviewModal
onClose={() => setPreviewVideoUrl(null)}
videoUrl={previewVideoUrl}
title="发布视频预览"
/>
{/* QR码弹窗 */}
{qrPlatform && (
<div className="fixed inset-0 bg-black/80 flex items-center justify-center z-50">
<div className="bg-white rounded-2xl p-8 max-w-md min-w-[320px]">
@@ -312,12 +354,13 @@ export default function PublishPage() {
IPAgent
</Link>
<div className="flex items-center gap-1 sm:gap-4">
<Link
href="/"
className="px-2 sm:px-4 py-1 sm:py-2 text-sm sm:text-base bg-white/10 hover:bg-white/20 text-white rounded-lg transition-colors"
>
</Link>
<Link
href="/"
className="px-2 sm:px-4 py-1 sm:py-2 text-sm sm:text-base bg-white/10 hover:bg-white/20 text-white rounded-lg transition-colors flex items-center gap-1"
>
<ArrowLeft className="h-4 w-4" />
</Link>
<span className="px-2 sm:px-4 py-1 sm:py-2 text-sm sm:text-base bg-gradient-to-r from-purple-600 to-pink-600 text-white rounded-lg font-semibold">
</span>
@@ -362,26 +405,29 @@ export default function PublishPage() {
<div className="flex gap-2">
{account.logged_in ? (
<>
<button
onClick={() => handleLogin(account.platform)}
className="px-3 py-1 bg-white/10 hover:bg-white/20 text-white text-sm rounded-lg transition-colors"
>
</button>
<button
onClick={() => handleLogout(account.platform)}
className="px-3 py-1 bg-red-500/80 hover:bg-red-600 text-white text-sm rounded-lg transition-colors"
>
</button>
<button
onClick={() => handleLogin(account.platform)}
className="px-3 py-1 bg-white/10 hover:bg-white/20 text-white text-sm rounded-lg transition-colors flex items-center gap-1"
>
<RotateCcw className="h-3.5 w-3.5" />
</button>
<button
onClick={() => handleLogout(account.platform)}
className="px-3 py-1 bg-red-500/80 hover:bg-red-600 text-white text-sm rounded-lg transition-colors flex items-center gap-1"
>
<LogOut className="h-3.5 w-3.5" />
</button>
</>
) : (
<button
onClick={() => handleLogin(account.platform)}
className="px-3 py-1 bg-purple-600 hover:bg-purple-700 text-white text-sm rounded-lg transition-colors"
>
🔐
</button>
<button
onClick={() => handleLogin(account.platform)}
className="px-3 py-1 bg-purple-600 hover:bg-purple-700 text-white text-sm rounded-lg transition-colors flex items-center gap-1"
>
<QrCode className="h-3.5 w-3.5" />
</button>
)}
</div>
</div>
@@ -392,33 +438,92 @@ export default function PublishPage() {
{/* 右侧: 发布表单 */}
<div className="space-y-6">
{/* 选择视频 */}
<div className="bg-white/5 rounded-2xl p-6 border border-white/10 backdrop-blur-sm">
<h2 className="text-lg font-semibold text-white mb-4">
🎥
</h2>
{videos.length === 0 ? (
<p className="text-gray-400">
<Link href="/" className="text-purple-400 hover:underline">
</Link>
</p>
) : (
<select
value={selectedVideo}
onChange={(e) => setSelectedVideo(e.target.value)}
className="w-full p-3 bg-black/30 border border-white/10 rounded-xl text-white custom-select cursor-pointer hover:border-purple-500/50 transition-colors"
>
{videos.map((v) => (
<option key={v.path} value={v.path}>
{v.name}
</option>
))}
</select>
)}
</div>
{/* 选择视频 */}
<div className="bg-white/5 rounded-2xl p-6 border border-white/10 backdrop-blur-sm">
<h2 className="text-lg font-semibold text-white mb-4">
🎥
</h2>
{videos.length === 0 ? (
<p className="text-gray-400">
<Link href="/" className="text-purple-400 hover:underline">
</Link>
</p>
) : (
<>
<div className="flex items-center gap-2 mb-3">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-500" />
<input
value={videoFilter}
onChange={(e) => setVideoFilter(e.target.value)}
placeholder="搜索视频..."
className="w-full pl-9 pr-3 py-2 bg-black/30 border border-white/10 rounded-lg text-white text-sm placeholder-gray-500 focus:outline-none focus:border-purple-500 transition-colors"
/>
</div>
<button
onClick={fetchVideos}
className="px-2 py-2 text-xs bg-white/10 hover:bg-white/20 rounded text-gray-300 flex items-center gap-1"
>
<RefreshCw className="h-3.5 w-3.5" />
</button>
</div>
{filteredVideos.length === 0 ? (
<div className="text-center py-4 text-gray-500 text-sm">
</div>
) : (
<div
className="space-y-2 max-h-64 overflow-y-auto hide-scrollbar"
style={{ contentVisibility: 'auto' }}
>
{filteredVideos.map((v) => (
<div
key={v.path}
className={`p-3 rounded-lg border transition-all flex items-center justify-between group ${selectedVideo === v.path
? "border-purple-500 bg-purple-500/20"
: "border-white/10 bg-white/5 hover:border-white/30"
}`}
>
<button
onClick={() => setSelectedVideo(v.path)}
className="flex-1 text-left"
>
<div className="text-white text-sm truncate">
{v.name}
</div>
</button>
<div className="flex items-center gap-2 pl-2">
<button
onClick={(e) => {
e.stopPropagation();
const previewPath = isAbsoluteUrl(v.path)
? v.path
: v.path.startsWith('/')
? v.path
: `/${v.path}`;
setPreviewVideoUrl(resolveMediaUrl(previewPath) || previewPath);
}}
className="p-1 text-gray-500 hover:text-purple-400 transition-colors"
title="预览"
>
<Eye className="h-4 w-4" />
</button>
{selectedVideo === v.path && (
<span className="text-xs text-purple-300"></span>
)}
</div>
</div>
))}
</div>
)}
</>
)}
</div>
{/* 填写信息 */}
<div className="bg-white/5 rounded-2xl p-6 border border-white/10 backdrop-blur-sm">
@@ -429,13 +534,28 @@ export default function PublishPage() {
<label className="block text-gray-400 text-sm mb-2">
</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="输入视频标题..."
className="w-full p-3 bg-black/30 border border-white/10 rounded-xl text-white placeholder-gray-500"
/>
<input
type="text"
value={title}
onChange={(e) => {
if (isTitleComposingRef.current) {
setTitle(e.target.value);
return;
}
const nextValue = applyTitleLimit(titleCommittedRef.current, e.target.value);
commitTitle(nextValue);
}}
onCompositionStart={() => {
isTitleComposingRef.current = true;
}}
onCompositionEnd={(e) => {
isTitleComposingRef.current = false;
const nextValue = applyTitleLimit(titleCommittedRef.current, e.currentTarget.value);
commitTitle(nextValue);
}}
placeholder="输入视频标题..."
className="w-full p-3 bg-black/30 border border-white/10 rounded-xl text-white placeholder-gray-500"
/>
</div>
<div>
<label className="block text-gray-400 text-sm mb-2">
@@ -487,32 +607,40 @@ export default function PublishPage() {
<div className="space-y-3">
<div className="flex gap-3">
{/* 立即发布 - 占 3/4 */}
<button
onClick={() => {
setScheduleMode("now");
handlePublish();
}}
disabled={isPublishing || selectedPlatforms.length === 0}
className={`flex-[3] py-4 rounded-xl font-bold text-lg transition-all ${isPublishing || selectedPlatforms.length === 0
? "bg-gray-600 cursor-not-allowed text-gray-400"
: "bg-gradient-to-r from-green-600 to-teal-600 hover:from-green-700 hover:to-teal-700 text-white"
}`}
>
{isPublishing && scheduleMode === "now" ? "发布中..." : "🚀 立即发布"}
</button>
<button
onClick={() => {
setScheduleMode("now");
handlePublish();
}}
disabled={isPublishing || selectedPlatforms.length === 0}
className={`flex-[3] py-4 rounded-xl font-bold text-lg transition-all flex items-center justify-center gap-2 ${isPublishing || selectedPlatforms.length === 0
? "bg-gray-600 cursor-not-allowed text-gray-400"
: "bg-gradient-to-r from-green-600 to-teal-600 hover:from-green-700 hover:to-teal-700 text-white"
}`}
>
{isPublishing && scheduleMode === "now" ? (
"发布中..."
) : (
<>
<Rocket className="h-5 w-5" />
</>
)}
</button>
{/* 定时发布 - 占 1/4 */}
<button
onClick={() => setScheduleMode(scheduleMode === "scheduled" ? "now" : "scheduled")}
disabled={isPublishing || selectedPlatforms.length === 0}
className={`flex-1 py-4 rounded-xl font-bold text-base transition-all ${isPublishing || selectedPlatforms.length === 0
? "bg-gray-600 cursor-not-allowed text-gray-400"
: scheduleMode === "scheduled"
? "bg-purple-600 text-white"
: "bg-white/10 hover:bg-white/20 text-white"
}`}
>
</button>
<button
onClick={() => setScheduleMode(scheduleMode === "scheduled" ? "now" : "scheduled")}
disabled={isPublishing || selectedPlatforms.length === 0}
className={`flex-1 py-4 rounded-xl font-bold text-base transition-all flex items-center justify-center gap-2 ${isPublishing || selectedPlatforms.length === 0
? "bg-gray-600 cursor-not-allowed text-gray-400"
: scheduleMode === "scheduled"
? "bg-purple-600 text-white"
: "bg-white/10 hover:bg-white/20 text-white"
}`}
>
<Clock className="h-5 w-5" />
</button>
</div>
{/* 定时发布时间选择器 */}
@@ -569,5 +697,5 @@ export default function PublishPage() {
</div>
</main>
</div>
);
}
);
}

View File

@@ -1,6 +1,6 @@
"use client";
import { useState, useRef, useEffect } from "react";
import { useState, useEffect } from "react";
import api from "@/lib/axios";
interface ScriptExtractionModalProps {

View File

@@ -1,50 +1,71 @@
"use client";
import { useEffect } from "react";
import { X, Video } from "lucide-react";
interface VideoPreviewModalProps {
videoUrl: string | null;
onClose: () => void;
title?: string;
subtitle?: string;
}
export default function VideoPreviewModal({ videoUrl, onClose }: VideoPreviewModalProps) {
useEffect(() => {
// 按 ESC 关闭
const handleEsc = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
if (videoUrl) {
document.addEventListener('keydown', handleEsc);
// 禁止背景滚动
document.body.style.overflow = 'hidden';
}
return () => {
document.removeEventListener('keydown', handleEsc);
document.body.style.overflow = 'unset';
};
}, [videoUrl, onClose]);
export default function VideoPreviewModal({
videoUrl,
onClose,
title = "视频预览",
subtitle = "ESC 关闭 · 点击空白关闭",
}: VideoPreviewModalProps) {
useEffect(() => {
if (!videoUrl) return;
// 按 ESC 关闭
const handleEsc = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
const prevOverflow = document.body.style.overflow;
document.addEventListener('keydown', handleEsc);
// 禁止背景滚动
document.body.style.overflow = 'hidden';
return () => {
document.removeEventListener('keydown', handleEsc);
document.body.style.overflow = prevOverflow;
};
}, [videoUrl, onClose]);
if (!videoUrl) return null;
return (
<div className="fixed inset-0 z-[200] flex items-center justify-center bg-black/80 backdrop-blur-sm p-4 animate-in fade-in duration-200">
<div className="relative w-full max-w-4xl bg-gray-900 border border-white/10 rounded-2xl shadow-2xl overflow-hidden flex flex-col">
{/* Header */}
<div className="flex items-center justify-between px-6 py-2 border-b border-white/10 bg-white/5">
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
🎥
</h3>
<div
className="fixed inset-0 z-[200] flex items-center justify-center bg-black/80 backdrop-blur-sm p-4 animate-in fade-in duration-200"
onClick={onClose}
>
<div
className="relative w-full max-w-4xl bg-gray-900 border border-white/10 rounded-2xl shadow-2xl overflow-hidden flex flex-col"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between px-6 py-3 border-b border-white/10 bg-gradient-to-r from-white/5 via-white/0 to-white/5">
<div className="flex items-center gap-3">
<div className="h-9 w-9 rounded-lg bg-white/10 flex items-center justify-center text-white">
<Video className="h-5 w-5" />
</div>
<div>
<h3 className="text-lg font-semibold text-white">
{title}
</h3>
<p className="text-xs text-gray-400">
{subtitle}
</p>
</div>
</div>
<button
onClick={onClose}
className="p-2 text-gray-400 hover:text-white hover:bg-white/10 rounded-lg transition-colors"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
<X className="h-5 w-5" />
</button>
</div>
{/* Video Player */}
<div className="bg-black flex items-center justify-center min-h-[50vh] max-h-[80vh]">
<video
src={videoUrl}
@@ -53,12 +74,7 @@ export default function VideoPreviewModal({ videoUrl, onClose }: VideoPreviewMod
className="w-full h-full max-h-[80vh] object-contain"
/>
</div>
</div>
{/* Click outside to close */}
<div className="absolute inset-0 -z-10" onClick={onClose}></div>
</div>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
</>
);
}

View File

@@ -0,0 +1,277 @@
import { useEffect, useState } from "react";
import type { MouseEvent } from "react";
import { Upload, RefreshCw, Play, Pause, Pencil, Trash2, Check, X, Mic, Square } from "lucide-react";
interface RefAudio {
id: string;
name: string;
path: string;
ref_text: string;
duration_sec: number;
created_at: number;
}
interface RefAudioPanelProps {
refAudios: RefAudio[];
selectedRefAudio: RefAudio | null;
onSelectRefAudio: (audio: RefAudio) => void;
isUploadingRef: boolean;
uploadRefError: string | null;
onClearUploadRefError: () => void;
onUploadRefAudio: (file: File) => void;
onFetchRefAudios: () => void;
playingAudioId: string | null;
onTogglePlayPreview: (audio: RefAudio, event: MouseEvent) => void;
editingAudioId: string | null;
editName: string;
onEditNameChange: (value: string) => void;
onStartEditing: (audio: RefAudio, event: MouseEvent) => void;
onSaveEditing: (id: string, event: MouseEvent) => void;
onCancelEditing: (event: MouseEvent) => void;
onDeleteRefAudio: (id: string) => void;
recordedBlob: Blob | null;
isRecording: boolean;
recordingTime: number;
onStartRecording: () => void;
onStopRecording: () => void;
onUseRecording: () => void;
formatRecordingTime: (seconds: number) => string;
fixedRefText: string;
}
export function RefAudioPanel({
refAudios,
selectedRefAudio,
onSelectRefAudio,
isUploadingRef,
uploadRefError,
onClearUploadRefError,
onUploadRefAudio,
onFetchRefAudios,
playingAudioId,
onTogglePlayPreview,
editingAudioId,
editName,
onEditNameChange,
onStartEditing,
onSaveEditing,
onCancelEditing,
onDeleteRefAudio,
recordedBlob,
isRecording,
recordingTime,
onStartRecording,
onStopRecording,
onUseRecording,
formatRecordingTime,
fixedRefText,
}: RefAudioPanelProps) {
const [recordedUrl, setRecordedUrl] = useState<string | null>(null);
useEffect(() => {
if (!recordedBlob) {
setRecordedUrl(null);
return;
}
const url = URL.createObjectURL(recordedBlob);
setRecordedUrl(url);
return () => {
URL.revokeObjectURL(url);
};
}, [recordedBlob]);
return (
<div className="space-y-4">
<div>
<div className="flex justify-between items-center mb-2">
<span className="text-sm text-gray-300">📁 </span>
<div className="flex gap-2">
<input
type="file"
id="ref-audio-upload"
accept=".wav,.mp3,.m4a,.webm,.ogg,.flac,.aac"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) {
onUploadRefAudio(file);
}
e.target.value = '';
}}
className="hidden"
/>
<label
htmlFor="ref-audio-upload"
className={`px-2 py-1 text-xs rounded cursor-pointer transition-all flex items-center gap-1 ${isUploadingRef
? "bg-gray-600 cursor-not-allowed text-gray-400"
: "bg-purple-600 hover:bg-purple-700 text-white"
}`}
>
<Upload className="h-3.5 w-3.5" />
</label>
<button
onClick={onFetchRefAudios}
className="px-2 py-1 text-xs bg-white/10 hover:bg-white/20 rounded text-gray-300 flex items-center gap-1"
>
<RefreshCw className="h-3.5 w-3.5" />
</button>
</div>
</div>
{isUploadingRef && (
<div className="mb-2 p-2 bg-purple-500/10 rounded text-sm text-purple-300">
...
</div>
)}
{uploadRefError && (
<div className="mb-2 p-2 bg-red-500/20 text-red-200 rounded text-xs flex justify-between">
<span> {uploadRefError}</span>
<button onClick={onClearUploadRefError} className="text-red-300 hover:text-white">
<X className="h-3.5 w-3.5" />
</button>
</div>
)}
{refAudios.length === 0 ? (
<div className="text-center py-4 text-gray-500 text-sm">
</div>
) : (
<div className="grid grid-cols-2 gap-2" style={{ contentVisibility: 'auto' }}>
{refAudios.map((audio) => (
<div
key={audio.id}
className={`p-2 rounded-lg border transition-all relative group cursor-pointer ${selectedRefAudio?.id === audio.id
? "border-purple-500 bg-purple-500/20"
: "border-white/10 bg-white/5 hover:border-white/30"
}`}
onClick={() => {
if (editingAudioId !== audio.id) {
onSelectRefAudio(audio);
}
}}
>
{editingAudioId === audio.id ? (
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
<input
type="text"
value={editName}
onChange={(e) => onEditNameChange(e.target.value)}
className="w-full bg-black/50 text-white text-xs px-1 py-0.5 rounded border border-purple-500 focus:outline-none"
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter') onSaveEditing(audio.id, e as any);
if (e.key === 'Escape') onCancelEditing(e as any);
}}
/>
<button onClick={(e) => onSaveEditing(audio.id, e)} className="text-green-400 hover:text-green-300 text-xs">
<Check className="h-3 w-3" />
</button>
<button onClick={(e) => onCancelEditing(e)} className="text-gray-400 hover:text-gray-300 text-xs">
<X className="h-3 w-3" />
</button>
</div>
) : (
<>
<div className="flex justify-between items-start mb-1">
<div className="text-white text-xs truncate pr-1 flex-1" title={audio.name}>
{audio.name}
</div>
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={(e) => onTogglePlayPreview(audio, e)}
className="text-gray-400 hover:text-purple-400 text-xs"
title="试听"
>
{playingAudioId === audio.id ? (
<Pause className="h-3.5 w-3.5" />
) : (
<Play className="h-3.5 w-3.5" />
)}
</button>
<button
onClick={(e) => onStartEditing(audio, e)}
className="text-gray-400 hover:text-blue-400 text-xs"
title="重命名"
>
<Pencil className="h-3.5 w-3.5" />
</button>
<button
onClick={(e) => {
e.stopPropagation();
onDeleteRefAudio(audio.id);
}}
className="text-gray-400 hover:text-red-400 text-xs"
title="删除"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</div>
<div className="text-gray-400 text-xs">{audio.duration_sec.toFixed(1)}s</div>
</>
)}
</div>
))}
</div>
)}
</div>
<div className="border-t border-white/10 pt-4">
<span className="text-sm text-gray-300 mb-2 block">🎤 线</span>
<div className="flex gap-2 items-center">
{!isRecording ? (
<button
onClick={onStartRecording}
className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg text-sm font-medium transition-colors flex items-center gap-2"
>
<Mic className="h-4 w-4" />
</button>
) : (
<button
onClick={onStopRecording}
className="px-4 py-2 bg-gray-600 hover:bg-gray-700 text-white rounded-lg text-sm font-medium transition-colors flex items-center gap-2"
>
<Square className="h-4 w-4" />
</button>
)}
{isRecording && (
<span className="text-red-400 text-sm animate-pulse">
🔴 {formatRecordingTime(recordingTime)}
</span>
)}
</div>
{recordedBlob && !isRecording && (
<div className="mt-3 p-3 bg-green-500/10 border border-green-500/30 rounded-lg">
<div className="flex items-center gap-2 mb-2">
<span className="text-green-300 text-sm"> ({formatRecordingTime(recordingTime)})</span>
<audio src={recordedUrl || ''} controls className="h-8" />
</div>
<button
onClick={onUseRecording}
disabled={isUploadingRef}
className="px-3 py-1 bg-green-600 hover:bg-green-700 text-white rounded text-sm disabled:bg-gray-600"
>
使
</button>
</div>
)}
</div>
<div className="border-t border-white/10 pt-4">
<label className="text-sm text-gray-300 mb-2 block">📝 /</label>
<div className="w-full bg-black/30 border border-white/10 rounded-lg p-3 text-white text-sm">
{fixedRefText}
</div>
<p className="text-xs text-gray-500 mt-1">
</p>
</div>
</div>
);
}

View 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>
);
}

View File

@@ -0,0 +1,315 @@
import type { RefObject } from "react";
import { Eye } from "lucide-react";
interface SubtitleStyleOption {
id: string;
label: string;
font_family?: string;
font_file?: string;
font_size?: number;
highlight_color?: string;
normal_color?: string;
stroke_color?: string;
stroke_size?: number;
letter_spacing?: number;
bottom_margin?: number;
is_default?: boolean;
}
interface TitleStyleOption {
id: string;
label: string;
font_family?: string;
font_file?: string;
font_size?: number;
color?: string;
stroke_color?: string;
stroke_size?: number;
letter_spacing?: number;
font_weight?: number;
top_margin?: number;
is_default?: boolean;
}
interface TitleSubtitlePanelProps {
showStylePreview: boolean;
onTogglePreview: () => void;
videoTitle: string;
onTitleChange: (value: string) => void;
onTitleCompositionStart?: () => void;
onTitleCompositionEnd?: (value: string) => void;
titleStyles: TitleStyleOption[];
selectedTitleStyleId: string;
onSelectTitleStyle: (id: string) => void;
titleFontSize: number;
onTitleFontSizeChange: (value: number) => void;
subtitleStyles: SubtitleStyleOption[];
selectedSubtitleStyleId: string;
onSelectSubtitleStyle: (id: string) => void;
subtitleFontSize: number;
onSubtitleFontSizeChange: (value: number) => void;
enableSubtitles: boolean;
onToggleSubtitles: (value: boolean) => void;
resolveAssetUrl: (path?: string | null) => string | null;
getFontFormat: (fontFile?: string) => string;
buildTextShadow: (color: string, size: number) => string;
previewScale?: number;
previewAspectRatio?: string;
previewBaseWidth?: number;
previewBaseHeight?: number;
previewContainerRef?: RefObject<HTMLDivElement | null>;
}
export function TitleSubtitlePanel({
showStylePreview,
onTogglePreview,
videoTitle,
onTitleChange,
onTitleCompositionStart,
onTitleCompositionEnd,
titleStyles,
selectedTitleStyleId,
onSelectTitleStyle,
titleFontSize,
onTitleFontSizeChange,
subtitleStyles,
selectedSubtitleStyleId,
onSelectSubtitleStyle,
subtitleFontSize,
onSubtitleFontSizeChange,
enableSubtitles,
onToggleSubtitles,
resolveAssetUrl,
getFontFormat,
buildTextShadow,
previewScale = 1,
previewAspectRatio = '16 / 9',
previewBaseWidth = 1280,
previewBaseHeight = 720,
previewContainerRef,
}: TitleSubtitlePanelProps) {
const activeSubtitleStyle = subtitleStyles.find((s) => s.id === selectedSubtitleStyleId)
|| subtitleStyles.find((s) => s.is_default)
|| subtitleStyles[0];
const activeTitleStyle = titleStyles.find((s) => s.id === selectedTitleStyleId)
|| titleStyles.find((s) => s.is_default)
|| titleStyles[0];
const previewTitleText = videoTitle.trim() || "这里是标题预览";
const subtitleHighlightText = "最近一个叫Cloudbot";
const subtitleNormalText = "的开源项目在GitHub上彻底火了";
const subtitleHighlightColor = activeSubtitleStyle?.highlight_color || "#FFE600";
const subtitleNormalColor = activeSubtitleStyle?.normal_color || "#FFFFFF";
const subtitleStrokeColor = activeSubtitleStyle?.stroke_color || "#000000";
const subtitleStrokeSize = activeSubtitleStyle?.stroke_size ?? 3;
const subtitleLetterSpacing = activeSubtitleStyle?.letter_spacing ?? 2;
const subtitleBottomMargin = activeSubtitleStyle?.bottom_margin ?? 0;
const subtitleFontFamilyName = `SubtitlePreview-${activeSubtitleStyle?.id || "default"}`;
const subtitleFontUrl = activeSubtitleStyle?.font_file
? resolveAssetUrl(`fonts/${activeSubtitleStyle.font_file}`)
: null;
const titleColor = activeTitleStyle?.color || "#FFFFFF";
const titleStrokeColor = activeTitleStyle?.stroke_color || "#000000";
const titleStrokeSize = activeTitleStyle?.stroke_size ?? 8;
const titleLetterSpacing = activeTitleStyle?.letter_spacing ?? 4;
const titleTopMargin = activeTitleStyle?.top_margin ?? 0;
const titleFontWeight = activeTitleStyle?.font_weight ?? 900;
const titleFontFamilyName = `TitlePreview-${activeTitleStyle?.id || "default"}`;
const titleFontUrl = activeTitleStyle?.font_file
? resolveAssetUrl(`fonts/${activeTitleStyle.font_file}`)
: null;
return (
<div className="bg-white/5 rounded-2xl p-4 sm:p-6 border border-white/10 backdrop-blur-sm">
<div className="flex items-center justify-between mb-4 gap-2">
<h2 className="text-base sm:text-lg font-semibold text-white flex items-center gap-2">
🎬
</h2>
<button
onClick={onTogglePreview}
className="px-2 py-1 text-xs bg-white/10 hover:bg-white/20 rounded text-gray-300 flex items-center gap-1"
>
<Eye className="h-3.5 w-3.5" />
{showStylePreview ? "收起预览" : "预览样式"}
</button>
</div>
{showStylePreview && (
<div
ref={previewContainerRef}
className="mb-4 rounded-xl border border-white/10 bg-black/40 relative overflow-hidden"
style={{ aspectRatio: previewAspectRatio, minHeight: '180px' }}
>
{(titleFontUrl || subtitleFontUrl) && (
<style>{`
${titleFontUrl ? `@font-face { font-family: '${titleFontFamilyName}'; src: url('${titleFontUrl}') format('${getFontFormat(activeTitleStyle?.font_file)}'); font-weight: 400; font-style: normal; }` : ''}
${subtitleFontUrl ? `@font-face { font-family: '${subtitleFontFamilyName}'; src: url('${subtitleFontUrl}') format('${getFontFormat(activeSubtitleStyle?.font_file)}'); font-weight: 400; font-style: normal; }` : ''}
`}</style>
)}
<div className="absolute inset-0 opacity-20 bg-gradient-to-br from-purple-500/40 via-transparent to-pink-500/30" />
<div
className="absolute top-0 left-0"
style={{
width: `${previewBaseWidth}px`,
height: `${previewBaseHeight}px`,
transform: `scale(${previewScale})`,
transformOrigin: 'top left',
}}
>
<div
className="w-full text-center"
style={{
position: 'absolute',
top: `${titleTopMargin}px`,
left: 0,
right: 0,
color: titleColor,
fontSize: `${titleFontSize}px`,
fontWeight: titleFontWeight,
fontFamily: titleFontUrl
? `'${titleFontFamilyName}', "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans SC", sans-serif`
: '"PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans SC", sans-serif',
textShadow: buildTextShadow(titleStrokeColor, titleStrokeSize),
letterSpacing: `${titleLetterSpacing}px`,
lineHeight: 1.2,
opacity: videoTitle.trim() ? 1 : 0.7,
padding: '0 5%',
}}
>
{previewTitleText}
</div>
<div
className="w-full text-center"
style={{
position: 'absolute',
bottom: `${subtitleBottomMargin}px`,
left: 0,
right: 0,
fontSize: `${subtitleFontSize}px`,
fontFamily: subtitleFontUrl
? `'${subtitleFontFamilyName}', "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans SC", sans-serif`
: '"PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans SC", sans-serif',
textShadow: buildTextShadow(subtitleStrokeColor, subtitleStrokeSize),
letterSpacing: `${subtitleLetterSpacing}px`,
lineHeight: 1.35,
padding: '0 6%',
}}
>
{enableSubtitles ? (
<>
<span style={{ color: subtitleHighlightColor }}>{subtitleHighlightText}</span>
<span style={{ color: subtitleNormalColor }}>{subtitleNormalText}</span>
</>
) : (
<span className="text-gray-400 text-sm"></span>
)}
</div>
</div>
</div>
)}
<div className="mb-4">
<label className="text-sm text-gray-300 mb-2 block">15</label>
<input
type="text"
value={videoTitle}
onChange={(e) => onTitleChange(e.target.value)}
onCompositionStart={onTitleCompositionStart}
onCompositionEnd={(e) => onTitleCompositionEnd?.(e.currentTarget.value)}
placeholder="输入视频标题,将在片头显示"
className="w-full px-3 sm:px-4 py-2 text-sm sm:text-base bg-black/30 border border-white/10 rounded-xl text-white placeholder-gray-500 focus:outline-none focus:border-purple-500 transition-colors"
/>
</div>
{titleStyles.length > 0 && (
<div className="mb-4">
<label className="text-sm text-gray-300 mb-2 block"></label>
<div className="grid grid-cols-2 gap-2">
{titleStyles.map((style) => (
<button
key={style.id}
onClick={() => onSelectTitleStyle(style.id)}
className={`p-2 rounded-lg border transition-all text-left ${selectedTitleStyleId === style.id
? "border-purple-500 bg-purple-500/20"
: "border-white/10 bg-white/5 hover:border-white/30"
}`}
>
<div className="text-white text-sm truncate">{style.label}</div>
<div className="text-xs text-gray-400 truncate">
{style.font_family || style.font_file || ""}
</div>
</button>
))}
</div>
<div className="mt-3">
<label className="text-xs text-gray-400 mb-2 block">: {titleFontSize}px</label>
<input
type="range"
min="48"
max="110"
step="1"
value={titleFontSize}
onChange={(e) => onTitleFontSizeChange(parseInt(e.target.value, 10))}
className="w-full accent-purple-500"
/>
</div>
</div>
)}
{enableSubtitles && subtitleStyles.length > 0 && (
<div className="mt-4">
<label className="text-sm text-gray-300 mb-2 block"></label>
<div className="grid grid-cols-2 gap-2">
{subtitleStyles.map((style) => (
<button
key={style.id}
onClick={() => onSelectSubtitleStyle(style.id)}
className={`p-2 rounded-lg border transition-all text-left ${selectedSubtitleStyleId === style.id
? "border-purple-500 bg-purple-500/20"
: "border-white/10 bg-white/5 hover:border-white/30"
}`}
>
<div className="text-white text-sm truncate">{style.label}</div>
<div className="text-xs text-gray-400 truncate">
{style.font_family || style.font_file || ""}
</div>
</button>
))}
</div>
<div className="mt-3">
<label className="text-xs text-gray-400 mb-2 block">: {subtitleFontSize}px</label>
<input
type="range"
min="32"
max="90"
step="1"
value={subtitleFontSize}
onChange={(e) => onSubtitleFontSizeChange(parseInt(e.target.value, 10))}
className="w-full accent-purple-500"
/>
</div>
</div>
)}
<div className="mt-4 pt-4 border-t border-white/10 flex items-center justify-between">
<div>
<span className="text-sm text-gray-300"></span>
<p className="text-xs text-gray-500 mt-1">OK效果字幕</p>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={enableSubtitles}
onChange={(e) => onToggleSubtitles(e.target.checked)}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-gray-600 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-purple-600"></div>
</label>
</div>
</div>
);
}

View 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>
);
}

View File

@@ -0,0 +1,55 @@
import { useCallback, useState } from "react";
import api from "@/lib/axios";
export interface BgmItem {
id: string;
name: string;
ext?: string;
}
interface UseBgmOptions {
storageKey: string;
selectedBgmId: string;
setSelectedBgmId: React.Dispatch<React.SetStateAction<string>>;
}
export const useBgm = ({
storageKey,
selectedBgmId,
setSelectedBgmId,
}: UseBgmOptions) => {
const [bgmList, setBgmList] = useState<BgmItem[]>([]);
const [bgmLoading, setBgmLoading] = useState(false);
const [bgmError, setBgmError] = useState<string>("");
const fetchBgmList = useCallback(async () => {
setBgmLoading(true);
setBgmError("");
try {
const { data } = await api.get('/api/assets/bgm');
const items: BgmItem[] = Array.isArray(data.bgm) ? data.bgm : [];
setBgmList(items);
const savedBgmId = localStorage.getItem(`vigent_${storageKey}_bgmId`);
setSelectedBgmId((prev) => {
if (prev && items.some((item) => item.id === prev)) return prev;
if (savedBgmId && items.some((item) => item.id === savedBgmId)) return savedBgmId;
return items[0]?.id || "";
});
} catch (error: any) {
const message = error?.response?.data?.detail || error?.message || '加载失败';
setBgmError(message);
setBgmList([]);
console.error("获取背景音乐失败:", error);
} finally {
setBgmLoading(false);
}
}, [setSelectedBgmId, storageKey]);
return {
bgmList,
bgmLoading,
bgmError,
fetchBgmList,
};
};

View File

@@ -0,0 +1,81 @@
import { useCallback, useState } from "react";
import api from "@/lib/axios";
interface GeneratedVideo {
id: string;
name: string;
path: string;
size_mb: number;
created_at: number;
}
interface UseGeneratedVideosOptions {
storageKey: string;
selectedVideoId: string | null;
setSelectedVideoId: React.Dispatch<React.SetStateAction<string | null>>;
setGeneratedVideo: React.Dispatch<React.SetStateAction<string | null>>;
resolveMediaUrl: (url?: string | null) => string | null;
}
export const useGeneratedVideos = ({
storageKey,
selectedVideoId,
setSelectedVideoId,
setGeneratedVideo,
resolveMediaUrl,
}: UseGeneratedVideosOptions) => {
const [generatedVideos, setGeneratedVideos] = useState<GeneratedVideo[]>([]);
const fetchGeneratedVideos = useCallback(async (preferVideoId?: string) => {
try {
const { data } = await api.get('/api/videos/generated');
const videos: GeneratedVideo[] = data.videos || [];
setGeneratedVideos(videos);
const savedSelectedVideoId = localStorage.getItem(`vigent_${storageKey}_selectedVideoId`);
const currentId = preferVideoId || selectedVideoId || savedSelectedVideoId || null;
let nextId: string | null = null;
let nextUrl: string | null = null;
if (currentId) {
const found = videos.find(v => v.id === currentId);
if (found) {
nextId = found.id;
nextUrl = resolveMediaUrl(found.path);
}
}
if (!nextId && videos.length > 0) {
nextId = videos[0].id;
nextUrl = resolveMediaUrl(videos[0].path);
}
if (nextId) {
setSelectedVideoId(nextId);
setGeneratedVideo(nextUrl);
}
} catch (error) {
console.error("获取历史视频失败:", error);
}
}, [resolveMediaUrl, selectedVideoId, setGeneratedVideo, setSelectedVideoId, storageKey]);
const deleteVideo = useCallback(async (videoId: string) => {
if (!confirm("确定要删除这个视频吗?")) return;
try {
await api.delete(`/api/videos/generated/${videoId}`);
if (selectedVideoId === videoId) {
setSelectedVideoId(null);
setGeneratedVideo(null);
}
fetchGeneratedVideos();
} catch (error) {
alert("删除失败: " + error);
}
}, [fetchGeneratedVideos, selectedVideoId, setGeneratedVideo, setSelectedVideoId]);
return {
generatedVideos,
fetchGeneratedVideos,
deleteVideo,
};
};

View File

@@ -0,0 +1,253 @@
import { useEffect, useState } from "react";
const TITLE_MAX_LENGTH = 15;
const clampTitle = (value: string) => value.slice(0, TITLE_MAX_LENGTH);
interface RefAudio {
id: string;
name: string;
path: string;
ref_text: string;
duration_sec: number;
created_at: number;
}
interface UseHomePersistenceOptions {
isAuthLoading: boolean;
storageKey: string;
text: string;
setText: React.Dispatch<React.SetStateAction<string>>;
videoTitle: string;
setVideoTitle: React.Dispatch<React.SetStateAction<string>>;
enableSubtitles: boolean;
setEnableSubtitles: React.Dispatch<React.SetStateAction<boolean>>;
ttsMode: 'edgetts' | 'voiceclone';
setTtsMode: React.Dispatch<React.SetStateAction<'edgetts' | 'voiceclone'>>;
voice: string;
setVoice: React.Dispatch<React.SetStateAction<string>>;
selectedMaterial: string;
setSelectedMaterial: React.Dispatch<React.SetStateAction<string>>;
selectedSubtitleStyleId: string;
setSelectedSubtitleStyleId: React.Dispatch<React.SetStateAction<string>>;
selectedTitleStyleId: string;
setSelectedTitleStyleId: React.Dispatch<React.SetStateAction<string>>;
subtitleFontSize: number;
setSubtitleFontSize: React.Dispatch<React.SetStateAction<number>>;
titleFontSize: number;
setTitleFontSize: React.Dispatch<React.SetStateAction<number>>;
setSubtitleSizeLocked: React.Dispatch<React.SetStateAction<boolean>>;
setTitleSizeLocked: React.Dispatch<React.SetStateAction<boolean>>;
selectedBgmId: string;
setSelectedBgmId: React.Dispatch<React.SetStateAction<string>>;
bgmVolume: number;
setBgmVolume: React.Dispatch<React.SetStateAction<number>>;
enableBgm: boolean;
setEnableBgm: React.Dispatch<React.SetStateAction<boolean>>;
selectedVideoId: string | null;
setSelectedVideoId: React.Dispatch<React.SetStateAction<string | null>>;
selectedRefAudio: RefAudio | null;
}
export const useHomePersistence = ({
isAuthLoading,
storageKey,
text,
setText,
videoTitle,
setVideoTitle,
enableSubtitles,
setEnableSubtitles,
ttsMode,
setTtsMode,
voice,
setVoice,
selectedMaterial,
setSelectedMaterial,
selectedSubtitleStyleId,
setSelectedSubtitleStyleId,
selectedTitleStyleId,
setSelectedTitleStyleId,
subtitleFontSize,
setSubtitleFontSize,
titleFontSize,
setTitleFontSize,
setSubtitleSizeLocked,
setTitleSizeLocked,
selectedBgmId,
setSelectedBgmId,
bgmVolume,
setBgmVolume,
enableBgm,
setEnableBgm,
selectedVideoId,
setSelectedVideoId,
selectedRefAudio,
}: UseHomePersistenceOptions) => {
const [isRestored, setIsRestored] = useState(false);
useEffect(() => {
if (isAuthLoading) return;
const savedText = localStorage.getItem(`vigent_${storageKey}_text`);
const savedTitle = localStorage.getItem(`vigent_${storageKey}_title`);
const savedSubtitles = localStorage.getItem(`vigent_${storageKey}_subtitles`);
const savedTtsMode = localStorage.getItem(`vigent_${storageKey}_ttsMode`);
const savedVoice = localStorage.getItem(`vigent_${storageKey}_voice`);
const savedMaterial = localStorage.getItem(`vigent_${storageKey}_material`);
const savedSubtitleStyle = localStorage.getItem(`vigent_${storageKey}_subtitleStyle`);
const savedTitleStyle = localStorage.getItem(`vigent_${storageKey}_titleStyle`);
const savedSubtitleFontSize = localStorage.getItem(`vigent_${storageKey}_subtitleFontSize`);
const savedTitleFontSize = localStorage.getItem(`vigent_${storageKey}_titleFontSize`);
const savedBgmId = localStorage.getItem(`vigent_${storageKey}_bgmId`);
const savedSelectedVideoId = localStorage.getItem(`vigent_${storageKey}_selectedVideoId`);
const savedBgmVolume = localStorage.getItem(`vigent_${storageKey}_bgmVolume`);
const savedEnableBgm = localStorage.getItem(`vigent_${storageKey}_enableBgm`);
setText(savedText || "大家好,欢迎来到我的频道,今天给大家分享一些有趣的内容。");
setVideoTitle(savedTitle ? clampTitle(savedTitle) : "");
setEnableSubtitles(savedSubtitles !== null ? savedSubtitles === 'true' : true);
setTtsMode((savedTtsMode as 'edgetts' | 'voiceclone') || 'edgetts');
setVoice(savedVoice || "zh-CN-YunxiNeural");
if (savedMaterial) setSelectedMaterial(savedMaterial);
if (savedSubtitleStyle) setSelectedSubtitleStyleId(savedSubtitleStyle);
if (savedTitleStyle) setSelectedTitleStyleId(savedTitleStyle);
if (savedSubtitleFontSize) {
const parsed = parseInt(savedSubtitleFontSize, 10);
if (!Number.isNaN(parsed)) {
setSubtitleFontSize(parsed);
setSubtitleSizeLocked(true);
}
}
if (savedTitleFontSize) {
const parsed = parseInt(savedTitleFontSize, 10);
if (!Number.isNaN(parsed)) {
setTitleFontSize(parsed);
setTitleSizeLocked(true);
}
}
if (savedBgmId) setSelectedBgmId(savedBgmId);
if (savedBgmVolume) setBgmVolume(parseFloat(savedBgmVolume));
if (savedEnableBgm !== null) setEnableBgm(savedEnableBgm === 'true');
if (savedSelectedVideoId) setSelectedVideoId(savedSelectedVideoId);
setIsRestored(true);
}, [
isAuthLoading,
setBgmVolume,
setEnableBgm,
setEnableSubtitles,
setSelectedBgmId,
setSelectedMaterial,
setSelectedSubtitleStyleId,
setSelectedTitleStyleId,
setSelectedVideoId,
setSubtitleFontSize,
setSubtitleSizeLocked,
setText,
setTitleFontSize,
setTitleSizeLocked,
setTtsMode,
setVideoTitle,
setVoice,
storageKey,
]);
useEffect(() => {
if (!isRestored) return;
const timeout = setTimeout(() => {
localStorage.setItem(`vigent_${storageKey}_text`, text);
}, 300);
return () => clearTimeout(timeout);
}, [text, storageKey, isRestored]);
useEffect(() => {
if (!isRestored) return;
const timeout = setTimeout(() => {
localStorage.setItem(`vigent_${storageKey}_title`, videoTitle);
}, 300);
return () => clearTimeout(timeout);
}, [videoTitle, storageKey, isRestored]);
useEffect(() => {
if (isRestored) localStorage.setItem(`vigent_${storageKey}_subtitles`, String(enableSubtitles));
}, [enableSubtitles, storageKey, isRestored]);
useEffect(() => {
if (isRestored) localStorage.setItem(`vigent_${storageKey}_ttsMode`, ttsMode);
}, [ttsMode, storageKey, isRestored]);
useEffect(() => {
if (isRestored) localStorage.setItem(`vigent_${storageKey}_voice`, voice);
}, [voice, storageKey, isRestored]);
useEffect(() => {
if (isRestored && selectedMaterial) {
localStorage.setItem(`vigent_${storageKey}_material`, selectedMaterial);
}
}, [selectedMaterial, storageKey, isRestored]);
useEffect(() => {
if (isRestored && selectedSubtitleStyleId) {
localStorage.setItem(`vigent_${storageKey}_subtitleStyle`, selectedSubtitleStyleId);
}
}, [selectedSubtitleStyleId, storageKey, isRestored]);
useEffect(() => {
if (isRestored && selectedTitleStyleId) {
localStorage.setItem(`vigent_${storageKey}_titleStyle`, selectedTitleStyleId);
}
}, [selectedTitleStyleId, storageKey, isRestored]);
useEffect(() => {
if (isRestored) {
localStorage.setItem(`vigent_${storageKey}_subtitleFontSize`, String(subtitleFontSize));
}
}, [subtitleFontSize, storageKey, isRestored]);
useEffect(() => {
if (isRestored) {
localStorage.setItem(`vigent_${storageKey}_titleFontSize`, String(titleFontSize));
}
}, [titleFontSize, storageKey, isRestored]);
useEffect(() => {
if (isRestored) {
localStorage.setItem(`vigent_${storageKey}_bgmId`, selectedBgmId);
}
}, [selectedBgmId, storageKey, isRestored]);
useEffect(() => {
if (!isRestored) return;
const timeout = setTimeout(() => {
localStorage.setItem(`vigent_${storageKey}_bgmVolume`, String(bgmVolume));
}, 300);
return () => clearTimeout(timeout);
}, [bgmVolume, storageKey, isRestored]);
useEffect(() => {
if (isRestored) {
localStorage.setItem(`vigent_${storageKey}_enableBgm`, String(enableBgm));
}
}, [enableBgm, storageKey, isRestored]);
useEffect(() => {
if (!isRestored) return;
if (selectedVideoId) {
localStorage.setItem(`vigent_${storageKey}_selectedVideoId`, selectedVideoId);
} else {
localStorage.removeItem(`vigent_${storageKey}_selectedVideoId`);
}
}, [selectedVideoId, storageKey, isRestored]);
useEffect(() => {
if (isRestored && selectedRefAudio) {
localStorage.setItem(`vigent_${storageKey}_refAudioId`, selectedRefAudio.id);
}
}, [selectedRefAudio, storageKey, isRestored]);
return { isRestored };
};

View File

@@ -0,0 +1,121 @@
import { useCallback, useState } from "react";
import api from "@/lib/axios";
interface Material {
id: string;
name: string;
scene: string;
size_mb: number;
path: string;
}
interface UseMaterialsOptions {
selectedMaterial: string;
setSelectedMaterial: React.Dispatch<React.SetStateAction<string>>;
}
export const useMaterials = ({
selectedMaterial,
setSelectedMaterial,
}: UseMaterialsOptions) => {
const [materials, setMaterials] = useState<Material[]>([]);
const [fetchError, setFetchError] = useState<string | null>(null);
const [debugData, setDebugData] = useState<string>("");
const [isUploading, setIsUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState(0);
const [uploadError, setUploadError] = useState<string | null>(null);
const [uploadData, setUploadData] = useState<string>("");
const fetchMaterials = useCallback(async () => {
try {
setFetchError(null);
setDebugData("Loading...");
const { data } = await api.get(`/api/materials?t=${new Date().getTime()}`);
setDebugData(JSON.stringify(data).substring(0, 200));
const nextMaterials = data.materials || [];
setMaterials(nextMaterials);
const nextSelected = nextMaterials.find((item: Material) => item.id === selectedMaterial)?.id
|| nextMaterials[0]?.id
|| "";
if (nextSelected !== selectedMaterial) {
setSelectedMaterial(nextSelected);
}
} catch (error) {
console.error("获取素材失败:", error);
setFetchError(String(error));
setDebugData(`Error: ${String(error)}`);
}
}, [selectedMaterial, setSelectedMaterial]);
const deleteMaterial = useCallback(async (materialId: string) => {
if (!confirm("确定要删除这个素材吗?")) return;
try {
await api.delete(`/api/materials/${materialId}`);
fetchMaterials();
if (selectedMaterial === materialId) {
setSelectedMaterial("");
}
} catch (error) {
alert("删除失败: " + error);
}
}, [fetchMaterials, selectedMaterial, setSelectedMaterial]);
const handleUpload = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const validTypes = ['.mp4', '.mov', '.avi'];
const ext = file.name.toLowerCase().slice(file.name.lastIndexOf('.'));
if (!validTypes.includes(ext)) {
setUploadError('仅支持 MP4、MOV、AVI 格式');
return;
}
setIsUploading(true);
setUploadProgress(0);
setUploadError(null);
try {
const formData = new FormData();
formData.append('file', file);
await api.post('/api/materials', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
onUploadProgress: (progressEvent) => {
if (progressEvent.total) {
const progress = Math.round((progressEvent.loaded / progressEvent.total) * 100);
setUploadProgress(progress);
}
},
});
setUploadProgress(100);
setIsUploading(false);
fetchMaterials();
setUploadData("");
} catch (err: any) {
console.error("Upload failed:", err);
setIsUploading(false);
const errorMsg = err.response?.data?.detail || err.message || String(err);
setUploadError(`上传失败: ${errorMsg}`);
}
e.target.value = '';
}, [fetchMaterials]);
return {
materials,
fetchError,
debugData,
isUploading,
uploadProgress,
uploadError,
uploadData,
setUploadError,
fetchMaterials,
deleteMaterial,
handleUpload,
};
};

View File

@@ -0,0 +1,116 @@
import { useCallback, useEffect, useRef, useState } from "react";
import type { BgmItem } from "@/hooks/useBgm";
interface RefAudio {
id: string;
name: string;
path: string;
ref_text: string;
duration_sec: number;
created_at: number;
}
interface UseMediaPlayersOptions {
bgmVolume: number;
resolveBgmUrl: (bgmId?: string | null) => string | null;
resolveMediaUrl: (url?: string | null) => string | null;
setSelectedBgmId: React.Dispatch<React.SetStateAction<string>>;
setEnableBgm: React.Dispatch<React.SetStateAction<boolean>>;
}
export const useMediaPlayers = ({
bgmVolume,
resolveBgmUrl,
resolveMediaUrl,
setSelectedBgmId,
setEnableBgm,
}: UseMediaPlayersOptions) => {
const [playingAudioId, setPlayingAudioId] = useState<string | null>(null);
const [playingBgmId, setPlayingBgmId] = useState<string | null>(null);
const audioPlayerRef = useRef<HTMLAudioElement | null>(null);
const bgmPlayerRef = useRef<HTMLAudioElement | null>(null);
const stopAudio = useCallback(() => {
if (audioPlayerRef.current) {
audioPlayerRef.current.pause();
audioPlayerRef.current.currentTime = 0;
audioPlayerRef.current = null;
}
setPlayingAudioId(null);
}, []);
const stopBgm = useCallback(() => {
if (bgmPlayerRef.current) {
bgmPlayerRef.current.pause();
bgmPlayerRef.current.currentTime = 0;
bgmPlayerRef.current = null;
}
setPlayingBgmId(null);
}, []);
const togglePlayPreview = useCallback((audio: RefAudio, e: React.MouseEvent) => {
e.stopPropagation();
if (bgmPlayerRef.current) {
stopBgm();
}
if (playingAudioId === audio.id) {
stopAudio();
return;
}
stopAudio();
const audioUrl = resolveMediaUrl(audio.path) || audio.path;
if (!audioUrl) {
alert("无法播放该参考音频");
return;
}
const player = new Audio(audioUrl);
player.onended = () => setPlayingAudioId(null);
player.play().catch((err) => alert("播放失败: " + err));
audioPlayerRef.current = player;
setPlayingAudioId(audio.id);
}, [playingAudioId, resolveMediaUrl, stopAudio, stopBgm]);
const toggleBgmPreview = useCallback((bgm: BgmItem, e: React.MouseEvent) => {
e.stopPropagation();
setSelectedBgmId(bgm.id);
setEnableBgm(true);
const bgmUrl = resolveBgmUrl(bgm.id);
if (!bgmUrl) {
alert("无法播放该背景音乐");
return;
}
if (playingBgmId === bgm.id) {
stopBgm();
return;
}
stopAudio();
stopBgm();
const player = new Audio(bgmUrl);
player.volume = Math.max(0, Math.min(bgmVolume, 1));
player.onended = () => setPlayingBgmId(null);
player.play().catch((err) => alert("播放失败: " + err));
bgmPlayerRef.current = player;
setPlayingBgmId(bgm.id);
}, [bgmVolume, playingBgmId, resolveBgmUrl, setEnableBgm, setSelectedBgmId, stopAudio, stopBgm]);
useEffect(() => {
if (bgmPlayerRef.current) {
bgmPlayerRef.current.volume = Math.max(0, Math.min(bgmVolume, 1));
}
}, [bgmVolume]);
return {
playingAudioId,
playingBgmId,
togglePlayPreview,
toggleBgmPreview,
};
};

View File

@@ -0,0 +1,91 @@
import { useCallback, useState } from "react";
import api from "@/lib/axios";
interface RefAudio {
id: string;
name: string;
path: string;
ref_text: string;
duration_sec: number;
created_at: number;
}
interface UseRefAudiosOptions {
fixedRefText: string;
selectedRefAudio: RefAudio | null;
setSelectedRefAudio: React.Dispatch<React.SetStateAction<RefAudio | null>>;
setRefText: React.Dispatch<React.SetStateAction<string>>;
}
export const useRefAudios = ({
fixedRefText,
selectedRefAudio,
setSelectedRefAudio,
setRefText,
}: UseRefAudiosOptions) => {
const [refAudios, setRefAudios] = useState<RefAudio[]>([]);
const [isUploadingRef, setIsUploadingRef] = useState(false);
const [uploadRefError, setUploadRefError] = useState<string | null>(null);
const fetchRefAudios = useCallback(async () => {
try {
const { data } = await api.get('/api/ref-audios');
const items: RefAudio[] = data.items || [];
items.sort((a, b) => b.created_at - a.created_at);
setRefAudios(items);
} catch (error) {
console.error("获取参考音频失败:", error);
}
}, []);
const uploadRefAudio = useCallback(async (file: File) => {
const refTextInput = fixedRefText;
setIsUploadingRef(true);
setUploadRefError(null);
try {
const formData = new FormData();
formData.append('file', file);
formData.append('ref_text', refTextInput);
const { data } = await api.post('/api/ref-audios', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
await fetchRefAudios();
setSelectedRefAudio(data);
setRefText(data.ref_text);
setIsUploadingRef(false);
} catch (err: any) {
console.error("Upload ref audio failed:", err);
setIsUploadingRef(false);
const errorMsg = err.response?.data?.detail || err.message || String(err);
setUploadRefError(`上传失败: ${errorMsg}`);
}
}, [fetchRefAudios, fixedRefText, setRefText, setSelectedRefAudio]);
const deleteRefAudio = useCallback(async (audioId: string) => {
if (!confirm("确定要删除这个参考音频吗?")) return;
try {
await api.delete(`/api/ref-audios/${encodeURIComponent(audioId)}`);
fetchRefAudios();
if (selectedRefAudio?.id === audioId) {
setSelectedRefAudio(null);
setRefText('');
}
} catch (error) {
alert("删除失败: " + error);
}
}, [fetchRefAudios, selectedRefAudio, setRefText, setSelectedRefAudio]);
return {
refAudios,
isUploadingRef,
uploadRefError,
setUploadRefError,
fetchRefAudios,
uploadRefAudio,
deleteRefAudio,
};
};

View File

@@ -0,0 +1,98 @@
import { useCallback, useEffect, useState } from "react";
import api from "@/lib/axios";
export interface SubtitleStyleOption {
id: string;
label: string;
font_family?: string;
font_file?: string;
font_size?: number;
highlight_color?: string;
normal_color?: string;
stroke_color?: string;
stroke_size?: number;
letter_spacing?: number;
bottom_margin?: number;
is_default?: boolean;
}
export interface TitleStyleOption {
id: string;
label: string;
font_family?: string;
font_file?: string;
font_size?: number;
color?: string;
stroke_color?: string;
stroke_size?: number;
letter_spacing?: number;
font_weight?: number;
top_margin?: number;
is_default?: boolean;
}
interface UseTitleSubtitleStylesOptions {
isAuthLoading: boolean;
storageKey: string;
setSelectedSubtitleStyleId: React.Dispatch<React.SetStateAction<string>>;
setSelectedTitleStyleId: React.Dispatch<React.SetStateAction<string>>;
}
export const useTitleSubtitleStyles = ({
isAuthLoading,
storageKey,
setSelectedSubtitleStyleId,
setSelectedTitleStyleId,
}: UseTitleSubtitleStylesOptions) => {
const [subtitleStyles, setSubtitleStyles] = useState<SubtitleStyleOption[]>([]);
const [titleStyles, setTitleStyles] = useState<TitleStyleOption[]>([]);
const refreshSubtitleStyles = useCallback(async () => {
try {
const { data } = await api.get('/api/assets/subtitle-styles');
const styles: SubtitleStyleOption[] = data.styles || [];
setSubtitleStyles(styles);
const savedStyleId = localStorage.getItem(`vigent_${storageKey}_subtitleStyle`);
setSelectedSubtitleStyleId((prev) => {
if (prev && styles.some((s) => s.id === prev)) return prev;
if (savedStyleId && styles.some((s) => s.id === savedStyleId)) return savedStyleId;
const defaultStyle = styles.find((s) => s.is_default) || styles[0];
return defaultStyle?.id || "";
});
} catch (error) {
console.error("获取字幕样式失败:", error);
}
}, [setSelectedSubtitleStyleId, storageKey]);
const refreshTitleStyles = useCallback(async () => {
try {
const { data } = await api.get('/api/assets/title-styles');
const styles: TitleStyleOption[] = data.styles || [];
setTitleStyles(styles);
const savedStyleId = localStorage.getItem(`vigent_${storageKey}_titleStyle`);
setSelectedTitleStyleId((prev) => {
if (prev && styles.some((s) => s.id === prev)) return prev;
if (savedStyleId && styles.some((s) => s.id === savedStyleId)) return savedStyleId;
const defaultStyle = styles.find((s) => s.is_default) || styles[0];
return defaultStyle?.id || "";
});
} catch (error) {
console.error("获取标题样式失败:", error);
}
}, [setSelectedTitleStyleId, storageKey]);
useEffect(() => {
if (isAuthLoading) return;
refreshSubtitleStyles();
refreshTitleStyles();
}, [isAuthLoading, refreshSubtitleStyles, refreshTitleStyles]);
return {
subtitleStyles,
titleStyles,
refreshSubtitleStyles,
refreshTitleStyles,
};
};

61
frontend/src/lib/media.ts Normal file
View 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}`;
};