Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aaa8088c82 | ||
|
|
31469ca01d | ||
|
|
22ea3dd0db | ||
|
|
8a5912c517 |
@@ -1,4 +1,5 @@
|
|||||||
## 🧩 前端 UI 拆分 (11:00)
|
# Day 17 - 前端重构与体验优化
|
||||||
|
|
||||||
## 🧩 前端 UI 拆分 (09:10)
|
## 🧩 前端 UI 拆分 (09:10)
|
||||||
|
|
||||||
### 内容
|
### 内容
|
||||||
@@ -85,5 +86,70 @@
|
|||||||
- “视频预览” → “作品预览”
|
- “视频预览” → “作品预览”
|
||||||
- “历史视频” → “历史作品”
|
- “历史视频” → “历史作品”
|
||||||
- “选择要发布的视频” → “选择要发布的作品”
|
- “选择要发布的视频” → “选择要发布的作品”
|
||||||
|
- “选择素材视频” → “视频素材”
|
||||||
|
- “选择配音方式” → “配音方式”
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 🧱 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`
|
||||||
|
|||||||
@@ -167,6 +167,7 @@ const timeText = formatDate(video.created_at);
|
|||||||
### 资源路径规则
|
### 资源路径规则
|
||||||
- 视频/音频:优先用 `resolveMediaUrl()`
|
- 视频/音频:优先用 `resolveMediaUrl()`
|
||||||
- 字体/BGM:使用 `resolveAssetUrl()`(自动编码中文路径)
|
- 字体/BGM:使用 `resolveAssetUrl()`(自动编码中文路径)
|
||||||
|
- 预览前若已有签名 URL,先用 `isAbsoluteUrl()` 判定,避免再次拼接
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -214,10 +215,20 @@ import { formatDate } from '@/lib/media';
|
|||||||
- 使用 `storageKey = userId || 'guest'`,按用户隔离。
|
- 使用 `storageKey = userId || 'guest'`,按用户隔离。
|
||||||
- **恢复先于保存**:恢复完成前禁止写入(`isRestored` 保护)。
|
- **恢复先于保存**:恢复完成前禁止写入(`isRestored` 保护)。
|
||||||
- 避免默认值覆盖用户选择(优先读取已保存值)。
|
- 避免默认值覆盖用户选择(优先读取已保存值)。
|
||||||
|
- 优先使用 `useHomePersistence` 集中管理恢复/保存,页面内避免分散的 localStorage 读写。
|
||||||
- 如需新增持久化字段,必须加入恢复与保存逻辑,并更新本节。
|
- 如需新增持久化字段,必须加入恢复与保存逻辑,并更新本节。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 标题输入规则
|
||||||
|
|
||||||
|
- 片头标题与发布信息标题统一限制 15 字。
|
||||||
|
- 中文输入法合成阶段不截断,合成结束后才校验长度。
|
||||||
|
- 首页片头标题修改会同步写入 `vigent_${storageKey}_publish_title`。
|
||||||
|
- 避免使用 `maxLength` 强制截断输入法合成态。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 新增页面 Checklist
|
## 新增页面 Checklist
|
||||||
|
|
||||||
1. [ ] 导入 `import api from '@/lib/axios'`
|
1. [ ] 导入 `import api from '@/lib/axios'`
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ ViGent2 的前端界面,采用 Next.js 16 + TailwindCSS 构建。
|
|||||||
- **预览一致性**: 标题/字幕预览按素材分辨率缩放,效果更接近成片 (Day 17)。
|
- **预览一致性**: 标题/字幕预览按素材分辨率缩放,效果更接近成片 (Day 17)。
|
||||||
- **进度追踪**: 实时显示视频生成进度 (10% -> 100%)。
|
- **进度追踪**: 实时显示视频生成进度 (10% -> 100%)。
|
||||||
- **作品预览**: 生成完成后直接播放下载(作品预览 + 历史作品)。
|
- **作品预览**: 生成完成后直接播放下载(作品预览 + 历史作品)。
|
||||||
- **本地保存**: 文案/标题自动保存,刷新后恢复 (Day 14)。
|
- **本地保存**: 文案/标题/偏好由 `useHomePersistence` 统一持久化,刷新后恢复 (Day 14/17)。
|
||||||
|
|
||||||
### 2. 全自动发布 (`/publish`) [Day 7 新增]
|
### 2. 全自动发布 (`/publish`) [Day 7 新增]
|
||||||
- **多平台管理**: 统一管理 B站、抖音、小红书账号状态。
|
- **多平台管理**: 统一管理 B站、抖音、小红书账号状态。
|
||||||
@@ -24,6 +24,7 @@ ViGent2 的前端界面,采用 Next.js 16 + TailwindCSS 构建。
|
|||||||
- Cookie 自动保存与状态同步。
|
- Cookie 自动保存与状态同步。
|
||||||
- **发布配置**: 设置视频标题、标签、简介。
|
- **发布配置**: 设置视频标题、标签、简介。
|
||||||
- **作品选择**: 卡片列表 + 搜索 + 预览弹窗。
|
- **作品选择**: 卡片列表 + 搜索 + 预览弹窗。
|
||||||
|
- **预览兼容**: 签名 URL / 相对路径均可直接预览。
|
||||||
- **定时任务**: 支持 "立即发布" 或 "定时发布"。
|
- **定时任务**: 支持 "立即发布" 或 "定时发布"。
|
||||||
|
|
||||||
### 3. 声音克隆 [Day 13 新增]
|
### 3. 声音克隆 [Day 13 新增]
|
||||||
@@ -32,7 +33,8 @@ ViGent2 的前端界面,采用 Next.js 16 + TailwindCSS 构建。
|
|||||||
- **一键克隆**: 选择参考音频后自动调用 Qwen3-TTS 服务。
|
- **一键克隆**: 选择参考音频后自动调用 Qwen3-TTS 服务。
|
||||||
|
|
||||||
### 4. 字幕与标题 [Day 13 新增]
|
### 4. 字幕与标题 [Day 13 新增]
|
||||||
- **片头标题**: 可选输入,视频开头显示 3 秒淡入淡出标题。
|
- **片头标题**: 可选输入,限制 15 字,视频开头显示 3 秒淡入淡出标题。
|
||||||
|
- **标题同步**: 首页片头标题修改会同步到发布信息标题。
|
||||||
- **逐字高亮字幕**: 卡拉OK效果,默认开启,可关闭。
|
- **逐字高亮字幕**: 卡拉OK效果,默认开启,可关闭。
|
||||||
- **自动对齐**: 基于 faster-whisper 生成字级别时间戳。
|
- **自动对齐**: 基于 faster-whisper 生成字级别时间戳。
|
||||||
- **样式预设**: 标题/字幕样式选择 + 预览 + 字号调节 (Day 16)。
|
- **样式预设**: 标题/字幕样式选择 + 预览 + 字号调节 (Day 16)。
|
||||||
|
|||||||
@@ -140,7 +140,7 @@ backend/
|
|||||||
|
|
||||||
| 端点 | 方法 | 功能 |
|
| 端点 | 方法 | 功能 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| `/api/materials` | POST | 上传素材视频 | ✅ |
|
| `/api/materials` | POST | 上传视频素材 | ✅ |
|
||||||
| `/api/materials` | GET | 获取素材列表 | ✅ |
|
| `/api/materials` | GET | 获取素材列表 | ✅ |
|
||||||
| `/api/videos/generate` | POST | 创建视频生成任务 | ✅ |
|
| `/api/videos/generate` | POST | 创建视频生成任务 | ✅ |
|
||||||
| `/api/videos/tasks/{id}` | GET | 查询任务状态 | ✅ |
|
| `/api/videos/tasks/{id}` | GET | 查询任务状态 | ✅ |
|
||||||
@@ -164,7 +164,7 @@ background_tasks.add_task(_process_video_generation, task_id, req, user_id)
|
|||||||
|
|
||||||
| 页面 | 功能 |
|
| 页面 | 功能 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| **素材库** | 上传/管理多场景素材视频 |
|
| **素材库** | 上传/管理多场景视频素材 |
|
||||||
| **生成视频** | 输入文案、选择素材、生成预览 |
|
| **生成视频** | 输入文案、选择素材、生成预览 |
|
||||||
| **任务中心** | 查看生成进度、下载视频 |
|
| **任务中心** | 查看生成进度、下载视频 |
|
||||||
| **发布管理** | 绑定平台、一键发布、定时发布 |
|
| **发布管理** | 绑定平台、一键发布、定时发布 |
|
||||||
|
|||||||
@@ -21,6 +21,9 @@
|
|||||||
- [x] **性能微优化**: 列表渲染优化 + 并行请求 + localStorage 防抖。
|
- [x] **性能微优化**: 列表渲染优化 + 并行请求 + localStorage 防抖。
|
||||||
- [x] **资源能力**: 字体/BGM 资源库 + `/api/assets` 接入。
|
- [x] **资源能力**: 字体/BGM 资源库 + `/api/assets` 接入。
|
||||||
- [x] **音频与字幕修复**: BGM 混音稳定性与字幕断句优化。
|
- [x] **音频与字幕修复**: BGM 混音稳定性与字幕断句优化。
|
||||||
|
- [x] **持久化修复**: 接入 `useHomePersistence`,恢复 `isRestored` 逻辑并通过构建。
|
||||||
|
- [x] **预览与选择修复**: 发布预览兼容签名 URL,音频试听路径解析,素材/BGM 回退有效项。
|
||||||
|
- [x] **体验细节优化**: 录音预览 URL 回收,预览弹窗滚动恢复,全局任务提示挂载。
|
||||||
|
|
||||||
### Day 16: 深度性能优化
|
### Day 16: 深度性能优化
|
||||||
- [x] **Qwen-TTS 加速**: 集成 Flash Attention 2,模型加载速度提升至 8.9s。
|
- [x] **Qwen-TTS 加速**: 集成 Flash Attention 2,模型加载速度提升至 8.9s。
|
||||||
|
|||||||
@@ -21,14 +21,16 @@
|
|||||||
- 📝 **智能字幕** - 集成 faster-whisper + Remotion,自动生成逐字高亮 (卡拉OK效果) 字幕。
|
- 📝 **智能字幕** - 集成 faster-whisper + Remotion,自动生成逐字高亮 (卡拉OK效果) 字幕。
|
||||||
- 🎨 **样式预设** - 标题/字幕样式选择 + 预览 + 字号调节,支持自定义字体库。
|
- 🎨 **样式预设** - 标题/字幕样式选择 + 预览 + 字号调节,支持自定义字体库。
|
||||||
- 🖼️ **作品预览一致性** - 标题/字幕预览按素材分辨率缩放,效果更接近成片。
|
- 🖼️ **作品预览一致性** - 标题/字幕预览按素材分辨率缩放,效果更接近成片。
|
||||||
|
- 💾 **用户偏好持久化** - 首页状态统一恢复/保存,刷新后延续上次配置。
|
||||||
- 🎵 **背景音乐** - 试听 + 音量控制 + 混音,保持配音音量稳定。
|
- 🎵 **背景音乐** - 试听 + 音量控制 + 混音,保持配音音量稳定。
|
||||||
- 🤖 **AI 辅助创作** - 内置 GLM-4.7-Flash,支持 B站/抖音链接文案提取、AI 洗稿、标题/标签自动生成。
|
- 🤖 **AI 辅助创作** - 内置 GLM-4.7-Flash,支持 B站/抖音链接文案提取、AI 洗稿、标题/标签自动生成。
|
||||||
|
|
||||||
### 平台化功能
|
### 平台化功能
|
||||||
- 📱 **全自动发布** - 支持 B站、抖音、小红书定时发布,扫码登录 + Cookie 持久化。
|
- 📱 **全自动发布** - 支持 B站、抖音、小红书定时发布,扫码登录 + Cookie 持久化。
|
||||||
- 🔐 **企业级认证** - 完善的用户隔离系统 (Supabase),支持手机号注册/登录、密码管理。
|
- 🖥️ **发布管理预览** - 支持签名 URL / 相对路径作品预览,确保可直接播放。
|
||||||
|
- 🔐 **认证与隔离** - 基于 Supabase 的用户隔离,支持手机号注册/登录、密码管理。
|
||||||
- 🛡️ **服务守护** - 内置 Watchdog 看门狗机制,自动监控并重启僵死服务,确保 7x24h 稳定运行。
|
- 🛡️ **服务守护** - 内置 Watchdog 看门狗机制,自动监控并重启僵死服务,确保 7x24h 稳定运行。
|
||||||
- 🚀 **极致性能** - 视频预压缩、模型常驻服务 (0s加载)、双 GPU 流水线并发。
|
- 🚀 **性能优化** - 视频预压缩、模型常驻服务(近实时加载)、双 GPU 流水线并发。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
{
|
{
|
||||||
"id": "subtitle_classic_yellow",
|
"id": "subtitle_classic_yellow",
|
||||||
"label": "经典黄字",
|
"label": "经典黄字",
|
||||||
"font_file": "title/思源黑体/SourceHanSansCN-Bold思源黑体免费.otf",
|
"font_file": "DingTalk JinBuTi.ttf",
|
||||||
"font_family": "SourceHanSansCN-Bold",
|
"font_family": "DingTalkJinBuTi",
|
||||||
"font_size": 52,
|
"font_size": 60,
|
||||||
"highlight_color": "#FFE600",
|
"highlight_color": "#FFE600",
|
||||||
"normal_color": "#FFFFFF",
|
"normal_color": "#FFFFFF",
|
||||||
"stroke_color": "#000000",
|
"stroke_color": "#000000",
|
||||||
|
|||||||
@@ -1,4 +1,18 @@
|
|||||||
[
|
[
|
||||||
|
{
|
||||||
|
"id": "title_pop",
|
||||||
|
"label": "站酷快乐体",
|
||||||
|
"font_file": "title/站酷快乐体.ttf",
|
||||||
|
"font_family": "ZCoolHappy",
|
||||||
|
"font_size": 90,
|
||||||
|
"color": "#FFFFFF",
|
||||||
|
"stroke_color": "#000000",
|
||||||
|
"stroke_size": 8,
|
||||||
|
"letter_spacing": 5,
|
||||||
|
"top_margin": 62,
|
||||||
|
"font_weight": 900,
|
||||||
|
"is_default": true
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "title_bold_white",
|
"id": "title_bold_white",
|
||||||
"label": "黑体大标题",
|
"label": "黑体大标题",
|
||||||
@@ -11,7 +25,7 @@
|
|||||||
"letter_spacing": 4,
|
"letter_spacing": 4,
|
||||||
"top_margin": 60,
|
"top_margin": 60,
|
||||||
"font_weight": 900,
|
"font_weight": 900,
|
||||||
"is_default": true
|
"is_default": false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "title_serif_gold",
|
"id": "title_serif_gold",
|
||||||
@@ -40,19 +54,5 @@
|
|||||||
"top_margin": 60,
|
"top_margin": 60,
|
||||||
"font_weight": 900,
|
"font_weight": 900,
|
||||||
"is_default": false
|
"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
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ export default function RootLayout({
|
|||||||
>
|
>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<TaskProvider>
|
<TaskProvider>
|
||||||
|
<GlobalTaskIndicator />
|
||||||
{children}
|
{children}
|
||||||
</TaskProvider>
|
</TaskProvider>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
|
|||||||
@@ -12,6 +12,15 @@ import {
|
|||||||
buildTextShadow,
|
buildTextShadow,
|
||||||
formatDate,
|
formatDate,
|
||||||
} from "@/lib/media";
|
} from "@/lib/media";
|
||||||
|
import { clampTitle } from "@/lib/title";
|
||||||
|
import { useTitleSubtitleStyles } from "@/hooks/useTitleSubtitleStyles";
|
||||||
|
import { useMaterials } from "@/hooks/useMaterials";
|
||||||
|
import { useRefAudios } from "@/hooks/useRefAudios";
|
||||||
|
import { useBgm } from "@/hooks/useBgm";
|
||||||
|
import { useMediaPlayers } from "@/hooks/useMediaPlayers";
|
||||||
|
import { useGeneratedVideos } from "@/hooks/useGeneratedVideos";
|
||||||
|
import { useHomePersistence } from "@/hooks/useHomePersistence";
|
||||||
|
import { useTitleInput } from "@/hooks/useTitleInput";
|
||||||
import { useAuth } from "@/contexts/AuthContext";
|
import { useAuth } from "@/contexts/AuthContext";
|
||||||
import { useTask } from "@/contexts/TaskContext";
|
import { useTask } from "@/contexts/TaskContext";
|
||||||
import VideoPreviewModal from "@/components/VideoPreviewModal";
|
import VideoPreviewModal from "@/components/VideoPreviewModal";
|
||||||
@@ -88,47 +97,11 @@ interface RefAudio {
|
|||||||
created_at: number;
|
created_at: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
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 BgmItem {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
ext?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const [materials, setMaterials] = useState<Material[]>([]);
|
|
||||||
const [selectedMaterial, setSelectedMaterial] = useState<string>("");
|
const [selectedMaterial, setSelectedMaterial] = useState<string>("");
|
||||||
const [previewMaterial, setPreviewMaterial] = useState<string | null>(null);
|
const [previewMaterial, setPreviewMaterial] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -139,21 +112,11 @@ export default function Home() {
|
|||||||
const { currentTask, isGenerating, startTask } = useTask();
|
const { currentTask, isGenerating, startTask } = useTask();
|
||||||
|
|
||||||
const [generatedVideo, setGeneratedVideo] = useState<string | null>(null);
|
const [generatedVideo, setGeneratedVideo] = useState<string | null>(null);
|
||||||
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 [generatedVideos, setGeneratedVideos] = useState<GeneratedVideo[]>([]);
|
|
||||||
|
|
||||||
const [selectedVideoId, setSelectedVideoId] = useState<string | null>(null);
|
const [selectedVideoId, setSelectedVideoId] = useState<string | null>(null);
|
||||||
|
|
||||||
// 字幕和标题相关状态
|
// 字幕和标题相关状态
|
||||||
const [videoTitle, setVideoTitle] = useState<string>("");
|
const [videoTitle, setVideoTitle] = useState<string>("");
|
||||||
const [enableSubtitles, setEnableSubtitles] = useState<boolean>(true);
|
const [enableSubtitles, setEnableSubtitles] = useState<boolean>(true);
|
||||||
const [subtitleStyles, setSubtitleStyles] = useState<SubtitleStyleOption[]>([]);
|
|
||||||
const [titleStyles, setTitleStyles] = useState<TitleStyleOption[]>([]);
|
|
||||||
const [selectedSubtitleStyleId, setSelectedSubtitleStyleId] = useState<string>("");
|
const [selectedSubtitleStyleId, setSelectedSubtitleStyleId] = useState<string>("");
|
||||||
const [selectedTitleStyleId, setSelectedTitleStyleId] = useState<string>("");
|
const [selectedTitleStyleId, setSelectedTitleStyleId] = useState<string>("");
|
||||||
const [subtitleFontSize, setSubtitleFontSize] = useState<number>(60);
|
const [subtitleFontSize, setSubtitleFontSize] = useState<number>(60);
|
||||||
@@ -165,107 +128,24 @@ export default function Home() {
|
|||||||
const [previewContainerWidth, setPreviewContainerWidth] = useState<number>(0);
|
const [previewContainerWidth, setPreviewContainerWidth] = useState<number>(0);
|
||||||
|
|
||||||
// 背景音乐相关状态
|
// 背景音乐相关状态
|
||||||
const [bgmList, setBgmList] = useState<BgmItem[]>([]);
|
|
||||||
const [selectedBgmId, setSelectedBgmId] = useState<string>("");
|
const [selectedBgmId, setSelectedBgmId] = useState<string>("");
|
||||||
const [enableBgm, setEnableBgm] = useState<boolean>(false);
|
const [enableBgm, setEnableBgm] = useState<boolean>(false);
|
||||||
const [bgmVolume, setBgmVolume] = useState<number>(0.2);
|
const [bgmVolume, setBgmVolume] = useState<number>(0.2);
|
||||||
const [playingBgmId, setPlayingBgmId] = useState<string | null>(null);
|
|
||||||
const [bgmLoading, setBgmLoading] = useState<boolean>(false);
|
|
||||||
const [bgmError, setBgmError] = useState<string>("");
|
|
||||||
|
|
||||||
// 声音克隆相关状态
|
// 声音克隆相关状态
|
||||||
const [ttsMode, setTtsMode] = useState<'edgetts' | 'voiceclone'>('edgetts');
|
const [ttsMode, setTtsMode] = useState<'edgetts' | 'voiceclone'>('edgetts');
|
||||||
const [refAudios, setRefAudios] = useState<RefAudio[]>([]);
|
|
||||||
const [selectedRefAudio, setSelectedRefAudio] = useState<RefAudio | null>(null);
|
const [selectedRefAudio, setSelectedRefAudio] = useState<RefAudio | null>(null);
|
||||||
const [refText, setRefText] = useState('其实生活中有许多美好的瞬间,比如清晨的阳光,或者一杯温热的清茶。希望这次生成的音色能够自然、流畅,完美还原出我最真实的声音状态。');
|
const [refText, setRefText] = useState('其实生活中有许多美好的瞬间,比如清晨的阳光,或者一杯温热的清茶。希望这次生成的音色能够自然、流畅,完美还原出我最真实的声音状态。');
|
||||||
const [isUploadingRef, setIsUploadingRef] = useState(false);
|
|
||||||
const [uploadRefError, setUploadRefError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
// 音频预览与重命名状态
|
// 音频预览与重命名状态
|
||||||
const [editingAudioId, setEditingAudioId] = useState<string | null>(null);
|
const [editingAudioId, setEditingAudioId] = useState<string | null>(null);
|
||||||
const [editName, setEditName] = useState("");
|
const [editName, setEditName] = useState("");
|
||||||
const [playingAudioId, setPlayingAudioId] = useState<string | null>(null);
|
|
||||||
const audioPlayerRef = useRef<HTMLAudioElement | null>(null);
|
|
||||||
const bgmPlayerRef = useRef<HTMLAudioElement | null>(null);
|
|
||||||
const bgmItemRefs = useRef<Record<string, HTMLDivElement | null>>({});
|
const bgmItemRefs = useRef<Record<string, HTMLDivElement | null>>({});
|
||||||
const bgmListContainerRef = useRef<HTMLDivElement | null>(null);
|
const bgmListContainerRef = useRef<HTMLDivElement | null>(null);
|
||||||
const titlePreviewContainerRef = useRef<HTMLDivElement | null>(null);
|
const titlePreviewContainerRef = useRef<HTMLDivElement | null>(null);
|
||||||
const materialItemRefs = useRef<Record<string, HTMLDivElement | null>>({});
|
const materialItemRefs = useRef<Record<string, HTMLDivElement | null>>({});
|
||||||
const videoItemRefs = useRef<Record<string, HTMLDivElement | null>>({});
|
const videoItemRefs = useRef<Record<string, HTMLDivElement | null>>({});
|
||||||
|
|
||||||
// 播放/暂停预览
|
|
||||||
const togglePlayPreview = (audio: RefAudio, e: React.MouseEvent) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
|
|
||||||
if (bgmPlayerRef.current) {
|
|
||||||
bgmPlayerRef.current.pause();
|
|
||||||
bgmPlayerRef.current.currentTime = 0;
|
|
||||||
bgmPlayerRef.current = null;
|
|
||||||
setPlayingBgmId(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (playingAudioId === audio.id) {
|
|
||||||
// 停止
|
|
||||||
if (audioPlayerRef.current) {
|
|
||||||
audioPlayerRef.current.pause();
|
|
||||||
audioPlayerRef.current.currentTime = 0;
|
|
||||||
}
|
|
||||||
setPlayingAudioId(null);
|
|
||||||
} else {
|
|
||||||
// 播放新的
|
|
||||||
if (audioPlayerRef.current) {
|
|
||||||
audioPlayerRef.current.pause();
|
|
||||||
}
|
|
||||||
const player = new Audio(audio.path);
|
|
||||||
player.onended = () => setPlayingAudioId(null);
|
|
||||||
player.play().catch(e => alert("播放失败: " + e));
|
|
||||||
audioPlayerRef.current = player;
|
|
||||||
setPlayingAudioId(audio.id);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 播放/暂停背景音乐预览
|
|
||||||
const toggleBgmPreview = (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) {
|
|
||||||
if (bgmPlayerRef.current) {
|
|
||||||
bgmPlayerRef.current.pause();
|
|
||||||
bgmPlayerRef.current.currentTime = 0;
|
|
||||||
}
|
|
||||||
bgmPlayerRef.current = null;
|
|
||||||
setPlayingBgmId(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (audioPlayerRef.current) {
|
|
||||||
audioPlayerRef.current.pause();
|
|
||||||
audioPlayerRef.current.currentTime = 0;
|
|
||||||
audioPlayerRef.current = null;
|
|
||||||
setPlayingAudioId(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (bgmPlayerRef.current) {
|
|
||||||
bgmPlayerRef.current.pause();
|
|
||||||
bgmPlayerRef.current.currentTime = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
const player = new Audio(bgmUrl);
|
|
||||||
player.volume = Math.max(0, Math.min(bgmVolume, 1));
|
|
||||||
player.onended = () => setPlayingBgmId(null);
|
|
||||||
player.play().catch(e => alert("播放失败: " + e));
|
|
||||||
bgmPlayerRef.current = player;
|
|
||||||
setPlayingBgmId(bgm.id);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 重命名参考音频
|
// 重命名参考音频
|
||||||
const startEditing = (audio: RefAudio, e: React.MouseEvent) => {
|
const startEditing = (audio: RefAudio, e: React.MouseEvent) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -306,8 +186,6 @@ export default function Home() {
|
|||||||
|
|
||||||
// 使用全局认证状态
|
// 使用全局认证状态
|
||||||
const { userId, isLoading: isAuthLoading } = useAuth();
|
const { userId, isLoading: isAuthLoading } = useAuth();
|
||||||
// 是否已从 localStorage 恢复完成
|
|
||||||
const [isRestored, setIsRestored] = useState(false);
|
|
||||||
|
|
||||||
// 文案提取模态框
|
// 文案提取模态框
|
||||||
const [extractModalOpen, setExtractModalOpen] = useState(false);
|
const [extractModalOpen, setExtractModalOpen] = useState(false);
|
||||||
@@ -317,6 +195,132 @@ export default function Home() {
|
|||||||
// 获取存储 key 的前缀(登录用户使用 userId,未登录使用 guest)
|
// 获取存储 key 的前缀(登录用户使用 userId,未登录使用 guest)
|
||||||
const storageKey = userId || 'guest';
|
const storageKey = userId || 'guest';
|
||||||
|
|
||||||
|
const {
|
||||||
|
materials,
|
||||||
|
fetchError,
|
||||||
|
isUploading,
|
||||||
|
uploadProgress,
|
||||||
|
uploadError,
|
||||||
|
setUploadError,
|
||||||
|
fetchMaterials,
|
||||||
|
deleteMaterial,
|
||||||
|
handleUpload,
|
||||||
|
} = useMaterials({
|
||||||
|
selectedMaterial,
|
||||||
|
setSelectedMaterial,
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
subtitleStyles,
|
||||||
|
titleStyles,
|
||||||
|
refreshSubtitleStyles,
|
||||||
|
refreshTitleStyles,
|
||||||
|
} = useTitleSubtitleStyles({
|
||||||
|
isAuthLoading,
|
||||||
|
storageKey,
|
||||||
|
setSelectedSubtitleStyleId,
|
||||||
|
setSelectedTitleStyleId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
refAudios,
|
||||||
|
isUploadingRef,
|
||||||
|
uploadRefError,
|
||||||
|
setUploadRefError,
|
||||||
|
fetchRefAudios,
|
||||||
|
uploadRefAudio,
|
||||||
|
deleteRefAudio,
|
||||||
|
} = useRefAudios({
|
||||||
|
fixedRefText: FIXED_REF_TEXT,
|
||||||
|
selectedRefAudio,
|
||||||
|
setSelectedRefAudio,
|
||||||
|
setRefText,
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
bgmList,
|
||||||
|
bgmLoading,
|
||||||
|
bgmError,
|
||||||
|
fetchBgmList,
|
||||||
|
} = useBgm({
|
||||||
|
storageKey,
|
||||||
|
selectedBgmId,
|
||||||
|
setSelectedBgmId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
playingAudioId,
|
||||||
|
playingBgmId,
|
||||||
|
togglePlayPreview,
|
||||||
|
toggleBgmPreview,
|
||||||
|
} = useMediaPlayers({
|
||||||
|
bgmVolume,
|
||||||
|
resolveBgmUrl,
|
||||||
|
resolveMediaUrl,
|
||||||
|
setSelectedBgmId,
|
||||||
|
setEnableBgm,
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
generatedVideos,
|
||||||
|
fetchGeneratedVideos,
|
||||||
|
deleteVideo,
|
||||||
|
} = useGeneratedVideos({
|
||||||
|
storageKey,
|
||||||
|
selectedVideoId,
|
||||||
|
setSelectedVideoId,
|
||||||
|
setGeneratedVideo,
|
||||||
|
resolveMediaUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { isRestored } = 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,
|
||||||
|
});
|
||||||
|
|
||||||
|
const syncTitleToPublish = (value: string) => {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
localStorage.setItem(`vigent_${storageKey}_publish_title`, value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const titleInput = useTitleInput({
|
||||||
|
value: videoTitle,
|
||||||
|
onChange: setVideoTitle,
|
||||||
|
onCommit: syncTitleToPublish,
|
||||||
|
});
|
||||||
|
|
||||||
// 加载素材列表和历史视频
|
// 加载素材列表和历史视频
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isAuthLoading) return;
|
if (isAuthLoading) return;
|
||||||
@@ -324,8 +328,8 @@ export default function Home() {
|
|||||||
fetchMaterials(),
|
fetchMaterials(),
|
||||||
fetchGeneratedVideos(),
|
fetchGeneratedVideos(),
|
||||||
fetchRefAudios(),
|
fetchRefAudios(),
|
||||||
fetchSubtitleStyles(),
|
refreshSubtitleStyles(),
|
||||||
fetchTitleStyles(),
|
refreshTitleStyles(),
|
||||||
fetchBgmList(),
|
fetchBgmList(),
|
||||||
]);
|
]);
|
||||||
}, [isAuthLoading]);
|
}, [isAuthLoading]);
|
||||||
@@ -407,261 +411,11 @@ export default function Home() {
|
|||||||
}
|
}
|
||||||
if (completedVideoId) {
|
if (completedVideoId) {
|
||||||
setSelectedVideoId(completedVideoId);
|
setSelectedVideoId(completedVideoId);
|
||||||
localStorage.setItem(`vigent_${storageKey}_selectedVideoId`, completedVideoId);
|
|
||||||
}
|
}
|
||||||
fetchGeneratedVideos(completedVideoId || undefined); // 刷新历史视频列表
|
fetchGeneratedVideos(completedVideoId || undefined); // 刷新历史视频列表
|
||||||
}
|
}
|
||||||
}, [currentTask?.status, currentTask?.download_url, currentTask?.task_id, storageKey]);
|
}, [currentTask?.status, currentTask?.download_url, currentTask?.task_id, storageKey]);
|
||||||
|
|
||||||
// 从 localStorage 恢复用户输入(等待认证完成后)
|
|
||||||
useEffect(() => {
|
|
||||||
console.log("[Home] 恢复检查 - isAuthLoading:", isAuthLoading, "userId:", userId);
|
|
||||||
if (isAuthLoading) return;
|
|
||||||
|
|
||||||
console.log("[Home] 开始从 localStorage 恢复数据,storageKey:", storageKey);
|
|
||||||
// 从 localStorage 恢复用户输入(带用户隔离,未登录用户使用 guest)
|
|
||||||
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`);
|
|
||||||
|
|
||||||
console.log("[Home] localStorage 数据:", { savedText, savedTitle, savedSubtitles, savedTtsMode, savedVoice, savedMaterial });
|
|
||||||
|
|
||||||
// 恢复数据,如果没有保存的数据则使用默认值
|
|
||||||
setText(savedText || "大家好,欢迎来到我的频道,今天给大家分享一些有趣的内容。");
|
|
||||||
setVideoTitle(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);
|
|
||||||
console.log("[Home] 恢复完成,isRestored = true");
|
|
||||||
}, [storageKey, isAuthLoading]);
|
|
||||||
|
|
||||||
// 保存用户输入到 localStorage(恢复完成后才保存,未登录用户也可保存)
|
|
||||||
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]);
|
|
||||||
|
|
||||||
const fetchMaterials = async () => {
|
|
||||||
try {
|
|
||||||
setFetchError(null);
|
|
||||||
setDebugData("Loading...");
|
|
||||||
|
|
||||||
const { data } = await api.get(`/api/materials?t=${new Date().getTime()}`);
|
|
||||||
setDebugData(JSON.stringify(data).substring(0, 200));
|
|
||||||
setMaterials(data.materials || []);
|
|
||||||
|
|
||||||
if (data.materials?.length > 0) {
|
|
||||||
if (!selectedMaterial) {
|
|
||||||
setSelectedMaterial(data.materials[0].id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("获取素材失败:", error);
|
|
||||||
setFetchError(String(error));
|
|
||||||
setDebugData(`Error: ${String(error)}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 获取已生成的视频列表(持久化)
|
|
||||||
const fetchGeneratedVideos = 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);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 获取参考音频列表
|
|
||||||
const fetchRefAudios = 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 fetchSubtitleStyles = 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);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 获取标题样式列表
|
|
||||||
const fetchTitleStyles = 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);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (subtitleSizeLocked || subtitleStyles.length === 0) return;
|
if (subtitleSizeLocked || subtitleStyles.length === 0) return;
|
||||||
const active = subtitleStyles.find(s => s.id === selectedSubtitleStyleId)
|
const active = subtitleStyles.find(s => s.id === selectedSubtitleStyleId)
|
||||||
@@ -682,34 +436,6 @@ export default function Home() {
|
|||||||
}
|
}
|
||||||
}, [titleStyles, selectedTitleStyleId, titleSizeLocked]);
|
}, [titleStyles, selectedTitleStyleId, titleSizeLocked]);
|
||||||
|
|
||||||
// 获取背景音乐列表
|
|
||||||
const fetchBgmList = 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 prev || (items[0]?.id || "");
|
|
||||||
});
|
|
||||||
} catch (error: any) {
|
|
||||||
const message = error?.response?.data?.detail || error?.message || '加载失败';
|
|
||||||
setBgmError(message);
|
|
||||||
setBgmList([]);
|
|
||||||
console.error("获取背景音乐失败:", error);
|
|
||||||
} finally {
|
|
||||||
setBgmLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!enableBgm || selectedBgmId || bgmList.length === 0) return;
|
if (!enableBgm || selectedBgmId || bgmList.length === 0) return;
|
||||||
const savedBgmId = localStorage.getItem(`vigent_${storageKey}_bgmId`);
|
const savedBgmId = localStorage.getItem(`vigent_${storageKey}_bgmId`);
|
||||||
@@ -721,12 +447,6 @@ export default function Home() {
|
|||||||
setSelectedBgmId(bgmList[0].id);
|
setSelectedBgmId(bgmList[0].id);
|
||||||
}, [enableBgm, selectedBgmId, bgmList, storageKey]);
|
}, [enableBgm, selectedBgmId, bgmList, storageKey]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (bgmPlayerRef.current) {
|
|
||||||
bgmPlayerRef.current.volume = Math.max(0, Math.min(bgmVolume, 1));
|
|
||||||
}
|
|
||||||
}, [bgmVolume]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedBgmId) return;
|
if (!selectedBgmId) return;
|
||||||
const container = bgmListContainerRef.current;
|
const container = bgmListContainerRef.current;
|
||||||
@@ -783,49 +503,6 @@ export default function Home() {
|
|||||||
}
|
}
|
||||||
}, [selectedRefAudio, storageKey, isRestored]);
|
}, [selectedRefAudio, storageKey, isRestored]);
|
||||||
|
|
||||||
// 上传参考音频(使用固定参考文字)
|
|
||||||
const uploadRefAudio = async (file: File) => {
|
|
||||||
const refTextInput = FIXED_REF_TEXT;
|
|
||||||
|
|
||||||
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}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 删除参考音频
|
|
||||||
const deleteRefAudio = 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);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 开始录音
|
// 开始录音
|
||||||
const startRecording = async () => {
|
const startRecording = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -901,11 +578,11 @@ export default function Home() {
|
|||||||
console.log("[Home] AI生成结果:", data);
|
console.log("[Home] AI生成结果:", data);
|
||||||
|
|
||||||
// 更新首页标题
|
// 更新首页标题
|
||||||
setVideoTitle(data.title || "");
|
const nextTitle = clampTitle(data.title || "");
|
||||||
|
titleInput.commitValue(nextTitle);
|
||||||
|
|
||||||
// 同步到发布页 localStorage
|
// 同步到发布页 localStorage
|
||||||
console.log("[Home] 保存到 publish localStorage - title:", data.title, "tags:", data.tags);
|
console.log("[Home] 保存到 publish localStorage - title:", data.title, "tags:", data.tags);
|
||||||
localStorage.setItem(`vigent_${storageKey}_publish_title`, data.title || "");
|
|
||||||
localStorage.setItem(`vigent_${storageKey}_publish_tags`, JSON.stringify(data.tags || []));
|
localStorage.setItem(`vigent_${storageKey}_publish_tags`, JSON.stringify(data.tags || []));
|
||||||
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
@@ -917,84 +594,6 @@ export default function Home() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 删除素材
|
|
||||||
const deleteMaterial = async (materialId: string) => {
|
|
||||||
if (!confirm("确定要删除这个素材吗?")) return;
|
|
||||||
try {
|
|
||||||
await api.delete(`/api/materials/${materialId}`);
|
|
||||||
fetchMaterials();
|
|
||||||
if (selectedMaterial === materialId) {
|
|
||||||
setSelectedMaterial("");
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
alert("删除失败: " + error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 删除生成的视频
|
|
||||||
const deleteVideo = async (videoId: string) => {
|
|
||||||
if (!confirm("确定要删除这个视频吗?")) return;
|
|
||||||
try {
|
|
||||||
await api.delete(`/api/videos/generated/${videoId}`);
|
|
||||||
fetchGeneratedVideos();
|
|
||||||
if (selectedVideoId === videoId) {
|
|
||||||
setSelectedVideoId(null);
|
|
||||||
setGeneratedVideo(null);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
alert("删除失败: " + error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 上传视频 - 使用 axios 支持进度显示
|
|
||||||
const handleUpload = 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}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 清空 input 以便可以再次选择同一文件
|
|
||||||
e.target.value = '';
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// 生成视频
|
// 生成视频
|
||||||
const handleGenerate = async () => {
|
const handleGenerate = async () => {
|
||||||
if (!selectedMaterial || !text.trim()) {
|
if (!selectedMaterial || !text.trim()) {
|
||||||
@@ -1121,7 +720,9 @@ export default function Home() {
|
|||||||
showStylePreview={showStylePreview}
|
showStylePreview={showStylePreview}
|
||||||
onTogglePreview={() => setShowStylePreview((prev) => !prev)}
|
onTogglePreview={() => setShowStylePreview((prev) => !prev)}
|
||||||
videoTitle={videoTitle}
|
videoTitle={videoTitle}
|
||||||
onTitleChange={setVideoTitle}
|
onTitleChange={titleInput.handleChange}
|
||||||
|
onTitleCompositionStart={titleInput.handleCompositionStart}
|
||||||
|
onTitleCompositionEnd={titleInput.handleCompositionEnd}
|
||||||
titleStyles={titleStyles}
|
titleStyles={titleStyles}
|
||||||
selectedTitleStyleId={selectedTitleStyleId}
|
selectedTitleStyleId={selectedTitleStyleId}
|
||||||
onSelectTitleStyle={setSelectedTitleStyleId}
|
onSelectTitleStyle={setSelectedTitleStyleId}
|
||||||
|
|||||||
@@ -4,10 +4,12 @@ import { useState, useEffect, useMemo } from "react";
|
|||||||
import useSWR from 'swr';
|
import useSWR from 'swr';
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import api from "@/lib/axios";
|
import api from "@/lib/axios";
|
||||||
import { getApiBaseUrl, formatDate } from "@/lib/media";
|
import { getApiBaseUrl, formatDate, resolveMediaUrl, isAbsoluteUrl } from "@/lib/media";
|
||||||
|
import { clampTitle } from "@/lib/title";
|
||||||
import { useAuth } from "@/contexts/AuthContext";
|
import { useAuth } from "@/contexts/AuthContext";
|
||||||
import AccountSettingsDropdown from "@/components/AccountSettingsDropdown";
|
import AccountSettingsDropdown from "@/components/AccountSettingsDropdown";
|
||||||
import VideoPreviewModal from "@/components/VideoPreviewModal";
|
import VideoPreviewModal from "@/components/VideoPreviewModal";
|
||||||
|
import { useTitleInput } from "@/hooks/useTitleInput";
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
@@ -38,16 +40,16 @@ interface Video {
|
|||||||
path: string;
|
path: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PublishPage() {
|
export default function PublishPage() {
|
||||||
const [accounts, setAccounts] = useState<Account[]>([]);
|
const [accounts, setAccounts] = useState<Account[]>([]);
|
||||||
const [videos, setVideos] = useState<Video[]>([]);
|
const [videos, setVideos] = useState<Video[]>([]);
|
||||||
const [selectedVideo, setSelectedVideo] = useState<string>("");
|
const [selectedVideo, setSelectedVideo] = useState<string>("");
|
||||||
const [videoFilter, setVideoFilter] = useState<string>("");
|
const [videoFilter, setVideoFilter] = useState<string>("");
|
||||||
const [previewVideoUrl, setPreviewVideoUrl] = useState<string | null>(null);
|
const [previewVideoUrl, setPreviewVideoUrl] = useState<string | null>(null);
|
||||||
const [selectedPlatforms, setSelectedPlatforms] = useState<string[]>([]);
|
const [selectedPlatforms, setSelectedPlatforms] = useState<string[]>([]);
|
||||||
const [title, setTitle] = useState<string>("");
|
const [title, setTitle] = useState<string>("");
|
||||||
const [tags, setTags] = useState<string>("");
|
const [tags, setTags] = useState<string>("");
|
||||||
const [isPublishing, setIsPublishing] = useState(false);
|
const [isPublishing, setIsPublishing] = useState(false);
|
||||||
const [publishResults, setPublishResults] = useState<any[]>([]);
|
const [publishResults, setPublishResults] = useState<any[]>([]);
|
||||||
const [scheduleMode, setScheduleMode] = useState<"now" | "scheduled">("now");
|
const [scheduleMode, setScheduleMode] = useState<"now" | "scheduled">("now");
|
||||||
const [publishTime, setPublishTime] = useState<string>("");
|
const [publishTime, setPublishTime] = useState<string>("");
|
||||||
@@ -57,8 +59,13 @@ export default function PublishPage() {
|
|||||||
|
|
||||||
// 使用全局认证状态
|
// 使用全局认证状态
|
||||||
const { userId, isLoading: isAuthLoading } = useAuth();
|
const { userId, isLoading: isAuthLoading } = useAuth();
|
||||||
// 是否已从 localStorage 恢复完成
|
// 是否已从 localStorage 恢复完成
|
||||||
const [isRestored, setIsRestored] = useState(false);
|
const [isRestored, setIsRestored] = useState(false);
|
||||||
|
|
||||||
|
const titleInput = useTitleInput({
|
||||||
|
value: title,
|
||||||
|
onChange: setTitle,
|
||||||
|
});
|
||||||
|
|
||||||
// 加载账号和视频列表
|
// 加载账号和视频列表
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -81,17 +88,13 @@ export default function PublishPage() {
|
|||||||
|
|
||||||
// 从 localStorage 恢复用户输入(等待认证完成后)
|
// 从 localStorage 恢复用户输入(等待认证完成后)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
console.log("[Publish] 恢复检查 - isAuthLoading:", isAuthLoading, "userId:", userId);
|
if (isAuthLoading) return;
|
||||||
if (isAuthLoading) return;
|
|
||||||
|
// 从 localStorage 恢复用户输入(带用户隔离,未登录用户使用 guest)
|
||||||
console.log("[Publish] 开始从 localStorage 恢复数据,storageKey:", storageKey);
|
const savedTitle = localStorage.getItem(`vigent_${storageKey}_publish_title`);
|
||||||
// 从 localStorage 恢复用户输入(带用户隔离,未登录用户使用 guest)
|
const savedTags = localStorage.getItem(`vigent_${storageKey}_publish_tags`);
|
||||||
const savedTitle = localStorage.getItem(`vigent_${storageKey}_publish_title`);
|
|
||||||
const savedTags = localStorage.getItem(`vigent_${storageKey}_publish_tags`);
|
if (savedTitle) setTitle(clampTitle(savedTitle));
|
||||||
|
|
||||||
console.log("[Publish] localStorage 数据:", { savedTitle, savedTags });
|
|
||||||
|
|
||||||
if (savedTitle) setTitle(savedTitle);
|
|
||||||
if (savedTags) {
|
if (savedTags) {
|
||||||
// 兼容 JSON 数组格式(AI 生成)和字符串格式(手动输入)
|
// 兼容 JSON 数组格式(AI 生成)和字符串格式(手动输入)
|
||||||
try {
|
try {
|
||||||
@@ -106,10 +109,9 @@ export default function PublishPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 恢复完成后才允许保存
|
// 恢复完成后才允许保存
|
||||||
setIsRestored(true);
|
setIsRestored(true);
|
||||||
console.log("[Publish] 恢复完成,isRestored = true");
|
}, [storageKey, isAuthLoading]);
|
||||||
}, [storageKey, isAuthLoading]);
|
|
||||||
|
|
||||||
// 保存用户输入到 localStorage(恢复完成后才保存,未登录用户也可保存)
|
// 保存用户输入到 localStorage(恢复完成后才保存,未登录用户也可保存)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -288,7 +290,7 @@ export default function PublishPage() {
|
|||||||
return videos.filter((v) => v.name.toLowerCase().includes(query));
|
return videos.filter((v) => v.name.toLowerCase().includes(query));
|
||||||
}, [videos, videoFilter]);
|
}, [videos, videoFilter]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-dvh">
|
<div className="min-h-dvh">
|
||||||
<VideoPreviewModal
|
<VideoPreviewModal
|
||||||
onClose={() => setPreviewVideoUrl(null)}
|
onClose={() => setPreviewVideoUrl(null)}
|
||||||
@@ -482,7 +484,12 @@ export default function PublishPage() {
|
|||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setPreviewVideoUrl(v.path);
|
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"
|
className="p-1 text-gray-500 hover:text-purple-400 transition-colors"
|
||||||
title="预览"
|
title="预览"
|
||||||
@@ -510,13 +517,15 @@ export default function PublishPage() {
|
|||||||
<label className="block text-gray-400 text-sm mb-2">
|
<label className="block text-gray-400 text-sm mb-2">
|
||||||
标题
|
标题
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={title}
|
value={title}
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
onChange={(e) => titleInput.handleChange(e.target.value)}
|
||||||
placeholder="输入视频标题..."
|
onCompositionStart={titleInput.handleCompositionStart}
|
||||||
className="w-full p-3 bg-black/30 border border-white/10 rounded-xl text-white placeholder-gray-500"
|
onCompositionEnd={(e) => titleInput.handleCompositionEnd(e.currentTarget.value)}
|
||||||
/>
|
placeholder="输入视频标题..."
|
||||||
|
className="w-full p-3 bg-black/30 border border-white/10 rounded-xl text-white placeholder-gray-500"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-gray-400 text-sm mb-2">
|
<label className="block text-gray-400 text-sm mb-2">
|
||||||
@@ -658,5 +667,5 @@ export default function PublishPage() {
|
|||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useRef, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import api from "@/lib/axios";
|
import api from "@/lib/axios";
|
||||||
|
|
||||||
interface ScriptExtractionModalProps {
|
interface ScriptExtractionModalProps {
|
||||||
|
|||||||
@@ -16,21 +16,22 @@ export default function VideoPreviewModal({
|
|||||||
title = "视频预览",
|
title = "视频预览",
|
||||||
subtitle = "ESC 关闭 · 点击空白关闭",
|
subtitle = "ESC 关闭 · 点击空白关闭",
|
||||||
}: VideoPreviewModalProps) {
|
}: VideoPreviewModalProps) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// 按 ESC 关闭
|
if (!videoUrl) return;
|
||||||
const handleEsc = (e: KeyboardEvent) => {
|
// 按 ESC 关闭
|
||||||
if (e.key === 'Escape') onClose();
|
const handleEsc = (e: KeyboardEvent) => {
|
||||||
};
|
if (e.key === 'Escape') onClose();
|
||||||
if (videoUrl) {
|
};
|
||||||
document.addEventListener('keydown', handleEsc);
|
const prevOverflow = document.body.style.overflow;
|
||||||
// 禁止背景滚动
|
document.addEventListener('keydown', handleEsc);
|
||||||
document.body.style.overflow = 'hidden';
|
// 禁止背景滚动
|
||||||
}
|
document.body.style.overflow = 'hidden';
|
||||||
return () => {
|
|
||||||
document.removeEventListener('keydown', handleEsc);
|
return () => {
|
||||||
document.body.style.overflow = 'unset';
|
document.removeEventListener('keydown', handleEsc);
|
||||||
};
|
document.body.style.overflow = prevOverflow;
|
||||||
}, [videoUrl, onClose]);
|
};
|
||||||
|
}, [videoUrl, onClose]);
|
||||||
|
|
||||||
if (!videoUrl) return null;
|
if (!videoUrl) return null;
|
||||||
|
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export function MaterialSelector({
|
|||||||
<div className="bg-white/5 rounded-2xl p-4 sm:p-6 border border-white/10 backdrop-blur-sm">
|
<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">
|
<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 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 className="ml-1 text-[11px] sm:text-xs text-gray-400/90 font-normal">
|
||||||
(上传自拍视频)
|
(上传自拍视频)
|
||||||
</span>
|
</span>
|
||||||
@@ -112,9 +112,9 @@ export function MaterialSelector({
|
|||||||
) : materials.length === 0 ? (
|
) : materials.length === 0 ? (
|
||||||
<div className="text-center py-8 text-gray-400">
|
<div className="text-center py-8 text-gray-400">
|
||||||
<div className="text-5xl mb-4">📁</div>
|
<div className="text-5xl mb-4">📁</div>
|
||||||
<p>暂无素材视频</p>
|
<p>暂无视频素材</p>
|
||||||
<p className="text-sm mt-2">
|
<p className="text-sm mt-2">
|
||||||
点击上方「📤 上传视频」按钮添加素材
|
点击上方「📤 上传视频」按钮添加视频素材
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
import type { MouseEvent } from "react";
|
import type { MouseEvent } from "react";
|
||||||
import { Upload, RefreshCw, Play, Pause, Pencil, Trash2, Check, X, Mic, Square } from "lucide-react";
|
import { Upload, RefreshCw, Play, Pause, Pencil, Trash2, Check, X, Mic, Square } from "lucide-react";
|
||||||
|
|
||||||
@@ -65,6 +66,20 @@ export function RefAudioPanel({
|
|||||||
formatRecordingTime,
|
formatRecordingTime,
|
||||||
fixedRefText,
|
fixedRefText,
|
||||||
}: RefAudioPanelProps) {
|
}: 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 (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
@@ -235,7 +250,7 @@ export function RefAudioPanel({
|
|||||||
<div className="mt-3 p-3 bg-green-500/10 border border-green-500/30 rounded-lg">
|
<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">
|
<div className="flex items-center gap-2 mb-2">
|
||||||
<span className="text-green-300 text-sm">✅ 录音完成 ({formatRecordingTime(recordingTime)})</span>
|
<span className="text-green-300 text-sm">✅ 录音完成 ({formatRecordingTime(recordingTime)})</span>
|
||||||
<audio src={URL.createObjectURL(recordedBlob)} controls className="h-8" />
|
<audio src={recordedUrl || ''} controls className="h-8" />
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={onUseRecording}
|
onClick={onUseRecording}
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ interface TitleSubtitlePanelProps {
|
|||||||
onTogglePreview: () => void;
|
onTogglePreview: () => void;
|
||||||
videoTitle: string;
|
videoTitle: string;
|
||||||
onTitleChange: (value: string) => void;
|
onTitleChange: (value: string) => void;
|
||||||
|
onTitleCompositionStart?: () => void;
|
||||||
|
onTitleCompositionEnd?: (value: string) => void;
|
||||||
titleStyles: TitleStyleOption[];
|
titleStyles: TitleStyleOption[];
|
||||||
selectedTitleStyleId: string;
|
selectedTitleStyleId: string;
|
||||||
onSelectTitleStyle: (id: string) => void;
|
onSelectTitleStyle: (id: string) => void;
|
||||||
@@ -63,6 +65,8 @@ export function TitleSubtitlePanel({
|
|||||||
onTogglePreview,
|
onTogglePreview,
|
||||||
videoTitle,
|
videoTitle,
|
||||||
onTitleChange,
|
onTitleChange,
|
||||||
|
onTitleCompositionStart,
|
||||||
|
onTitleCompositionEnd,
|
||||||
titleStyles,
|
titleStyles,
|
||||||
selectedTitleStyleId,
|
selectedTitleStyleId,
|
||||||
onSelectTitleStyle,
|
onSelectTitleStyle,
|
||||||
@@ -209,11 +213,13 @@ export function TitleSubtitlePanel({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<label className="text-sm text-gray-300 mb-2 block">片头标题(可选)</label>
|
<label className="text-sm text-gray-300 mb-2 block">片头标题(限制15个字)</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={videoTitle}
|
value={videoTitle}
|
||||||
onChange={(e) => onTitleChange(e.target.value)}
|
onChange={(e) => onTitleChange(e.target.value)}
|
||||||
|
onCompositionStart={onTitleCompositionStart}
|
||||||
|
onCompositionEnd={(e) => onTitleCompositionEnd?.(e.currentTarget.value)}
|
||||||
placeholder="输入视频标题,将在片头显示"
|
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"
|
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"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export function VoiceSelector({
|
|||||||
return (
|
return (
|
||||||
<div className="bg-white/5 rounded-2xl p-6 border border-white/10 backdrop-blur-sm">
|
<div className="bg-white/5 rounded-2xl p-6 border border-white/10 backdrop-blur-sm">
|
||||||
<h2 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
|
<h2 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
|
||||||
🎙️ 选择配音方式
|
🎙️ 配音方式
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div className="flex gap-2 mb-4">
|
<div className="flex gap-2 mb-4">
|
||||||
|
|||||||
55
frontend/src/hooks/useBgm.ts
Normal file
55
frontend/src/hooks/useBgm.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { useCallback, useState } from "react";
|
||||||
|
import api from "@/lib/axios";
|
||||||
|
|
||||||
|
export interface BgmItem {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
ext?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseBgmOptions {
|
||||||
|
storageKey: string;
|
||||||
|
selectedBgmId: string;
|
||||||
|
setSelectedBgmId: React.Dispatch<React.SetStateAction<string>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useBgm = ({
|
||||||
|
storageKey,
|
||||||
|
selectedBgmId,
|
||||||
|
setSelectedBgmId,
|
||||||
|
}: UseBgmOptions) => {
|
||||||
|
const [bgmList, setBgmList] = useState<BgmItem[]>([]);
|
||||||
|
const [bgmLoading, setBgmLoading] = useState(false);
|
||||||
|
const [bgmError, setBgmError] = useState<string>("");
|
||||||
|
|
||||||
|
const fetchBgmList = useCallback(async () => {
|
||||||
|
setBgmLoading(true);
|
||||||
|
setBgmError("");
|
||||||
|
try {
|
||||||
|
const { data } = await api.get('/api/assets/bgm');
|
||||||
|
const items: BgmItem[] = Array.isArray(data.bgm) ? data.bgm : [];
|
||||||
|
setBgmList(items);
|
||||||
|
|
||||||
|
const savedBgmId = localStorage.getItem(`vigent_${storageKey}_bgmId`);
|
||||||
|
setSelectedBgmId((prev) => {
|
||||||
|
if (prev && items.some((item) => item.id === prev)) return prev;
|
||||||
|
if (savedBgmId && items.some((item) => item.id === savedBgmId)) return savedBgmId;
|
||||||
|
return items[0]?.id || "";
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
const message = error?.response?.data?.detail || error?.message || '加载失败';
|
||||||
|
setBgmError(message);
|
||||||
|
setBgmList([]);
|
||||||
|
console.error("获取背景音乐失败:", error);
|
||||||
|
} finally {
|
||||||
|
setBgmLoading(false);
|
||||||
|
}
|
||||||
|
}, [setSelectedBgmId, storageKey]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
bgmList,
|
||||||
|
bgmLoading,
|
||||||
|
bgmError,
|
||||||
|
fetchBgmList,
|
||||||
|
};
|
||||||
|
};
|
||||||
81
frontend/src/hooks/useGeneratedVideos.ts
Normal file
81
frontend/src/hooks/useGeneratedVideos.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { useCallback, useState } from "react";
|
||||||
|
import api from "@/lib/axios";
|
||||||
|
|
||||||
|
interface GeneratedVideo {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
size_mb: number;
|
||||||
|
created_at: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseGeneratedVideosOptions {
|
||||||
|
storageKey: string;
|
||||||
|
selectedVideoId: string | null;
|
||||||
|
setSelectedVideoId: React.Dispatch<React.SetStateAction<string | null>>;
|
||||||
|
setGeneratedVideo: React.Dispatch<React.SetStateAction<string | null>>;
|
||||||
|
resolveMediaUrl: (url?: string | null) => string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useGeneratedVideos = ({
|
||||||
|
storageKey,
|
||||||
|
selectedVideoId,
|
||||||
|
setSelectedVideoId,
|
||||||
|
setGeneratedVideo,
|
||||||
|
resolveMediaUrl,
|
||||||
|
}: UseGeneratedVideosOptions) => {
|
||||||
|
const [generatedVideos, setGeneratedVideos] = useState<GeneratedVideo[]>([]);
|
||||||
|
|
||||||
|
const fetchGeneratedVideos = useCallback(async (preferVideoId?: string) => {
|
||||||
|
try {
|
||||||
|
const { data } = await api.get('/api/videos/generated');
|
||||||
|
const videos: GeneratedVideo[] = data.videos || [];
|
||||||
|
setGeneratedVideos(videos);
|
||||||
|
|
||||||
|
const savedSelectedVideoId = localStorage.getItem(`vigent_${storageKey}_selectedVideoId`);
|
||||||
|
const currentId = preferVideoId || selectedVideoId || savedSelectedVideoId || null;
|
||||||
|
let nextId: string | null = null;
|
||||||
|
let nextUrl: string | null = null;
|
||||||
|
|
||||||
|
if (currentId) {
|
||||||
|
const found = videos.find(v => v.id === currentId);
|
||||||
|
if (found) {
|
||||||
|
nextId = found.id;
|
||||||
|
nextUrl = resolveMediaUrl(found.path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!nextId && videos.length > 0) {
|
||||||
|
nextId = videos[0].id;
|
||||||
|
nextUrl = resolveMediaUrl(videos[0].path);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextId) {
|
||||||
|
setSelectedVideoId(nextId);
|
||||||
|
setGeneratedVideo(nextUrl);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("获取历史视频失败:", error);
|
||||||
|
}
|
||||||
|
}, [resolveMediaUrl, selectedVideoId, setGeneratedVideo, setSelectedVideoId, storageKey]);
|
||||||
|
|
||||||
|
const deleteVideo = useCallback(async (videoId: string) => {
|
||||||
|
if (!confirm("确定要删除这个视频吗?")) return;
|
||||||
|
try {
|
||||||
|
await api.delete(`/api/videos/generated/${videoId}`);
|
||||||
|
if (selectedVideoId === videoId) {
|
||||||
|
setSelectedVideoId(null);
|
||||||
|
setGeneratedVideo(null);
|
||||||
|
}
|
||||||
|
fetchGeneratedVideos();
|
||||||
|
} catch (error) {
|
||||||
|
alert("删除失败: " + error);
|
||||||
|
}
|
||||||
|
}, [fetchGeneratedVideos, selectedVideoId, setGeneratedVideo, setSelectedVideoId]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
generatedVideos,
|
||||||
|
fetchGeneratedVideos,
|
||||||
|
deleteVideo,
|
||||||
|
};
|
||||||
|
};
|
||||||
251
frontend/src/hooks/useHomePersistence.ts
Normal file
251
frontend/src/hooks/useHomePersistence.ts
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { clampTitle } from "@/lib/title";
|
||||||
|
|
||||||
|
interface RefAudio {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
ref_text: string;
|
||||||
|
duration_sec: number;
|
||||||
|
created_at: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseHomePersistenceOptions {
|
||||||
|
isAuthLoading: boolean;
|
||||||
|
storageKey: string;
|
||||||
|
text: string;
|
||||||
|
setText: React.Dispatch<React.SetStateAction<string>>;
|
||||||
|
videoTitle: string;
|
||||||
|
setVideoTitle: React.Dispatch<React.SetStateAction<string>>;
|
||||||
|
enableSubtitles: boolean;
|
||||||
|
setEnableSubtitles: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
ttsMode: 'edgetts' | 'voiceclone';
|
||||||
|
setTtsMode: React.Dispatch<React.SetStateAction<'edgetts' | 'voiceclone'>>;
|
||||||
|
voice: string;
|
||||||
|
setVoice: React.Dispatch<React.SetStateAction<string>>;
|
||||||
|
selectedMaterial: string;
|
||||||
|
setSelectedMaterial: React.Dispatch<React.SetStateAction<string>>;
|
||||||
|
selectedSubtitleStyleId: string;
|
||||||
|
setSelectedSubtitleStyleId: React.Dispatch<React.SetStateAction<string>>;
|
||||||
|
selectedTitleStyleId: string;
|
||||||
|
setSelectedTitleStyleId: React.Dispatch<React.SetStateAction<string>>;
|
||||||
|
subtitleFontSize: number;
|
||||||
|
setSubtitleFontSize: React.Dispatch<React.SetStateAction<number>>;
|
||||||
|
titleFontSize: number;
|
||||||
|
setTitleFontSize: React.Dispatch<React.SetStateAction<number>>;
|
||||||
|
setSubtitleSizeLocked: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
setTitleSizeLocked: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
selectedBgmId: string;
|
||||||
|
setSelectedBgmId: React.Dispatch<React.SetStateAction<string>>;
|
||||||
|
bgmVolume: number;
|
||||||
|
setBgmVolume: React.Dispatch<React.SetStateAction<number>>;
|
||||||
|
enableBgm: boolean;
|
||||||
|
setEnableBgm: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
selectedVideoId: string | null;
|
||||||
|
setSelectedVideoId: React.Dispatch<React.SetStateAction<string | null>>;
|
||||||
|
selectedRefAudio: RefAudio | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useHomePersistence = ({
|
||||||
|
isAuthLoading,
|
||||||
|
storageKey,
|
||||||
|
text,
|
||||||
|
setText,
|
||||||
|
videoTitle,
|
||||||
|
setVideoTitle,
|
||||||
|
enableSubtitles,
|
||||||
|
setEnableSubtitles,
|
||||||
|
ttsMode,
|
||||||
|
setTtsMode,
|
||||||
|
voice,
|
||||||
|
setVoice,
|
||||||
|
selectedMaterial,
|
||||||
|
setSelectedMaterial,
|
||||||
|
selectedSubtitleStyleId,
|
||||||
|
setSelectedSubtitleStyleId,
|
||||||
|
selectedTitleStyleId,
|
||||||
|
setSelectedTitleStyleId,
|
||||||
|
subtitleFontSize,
|
||||||
|
setSubtitleFontSize,
|
||||||
|
titleFontSize,
|
||||||
|
setTitleFontSize,
|
||||||
|
setSubtitleSizeLocked,
|
||||||
|
setTitleSizeLocked,
|
||||||
|
selectedBgmId,
|
||||||
|
setSelectedBgmId,
|
||||||
|
bgmVolume,
|
||||||
|
setBgmVolume,
|
||||||
|
enableBgm,
|
||||||
|
setEnableBgm,
|
||||||
|
selectedVideoId,
|
||||||
|
setSelectedVideoId,
|
||||||
|
selectedRefAudio,
|
||||||
|
}: UseHomePersistenceOptions) => {
|
||||||
|
const [isRestored, setIsRestored] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isAuthLoading) return;
|
||||||
|
|
||||||
|
const savedText = localStorage.getItem(`vigent_${storageKey}_text`);
|
||||||
|
const savedTitle = localStorage.getItem(`vigent_${storageKey}_title`);
|
||||||
|
const savedSubtitles = localStorage.getItem(`vigent_${storageKey}_subtitles`);
|
||||||
|
const savedTtsMode = localStorage.getItem(`vigent_${storageKey}_ttsMode`);
|
||||||
|
const savedVoice = localStorage.getItem(`vigent_${storageKey}_voice`);
|
||||||
|
const savedMaterial = localStorage.getItem(`vigent_${storageKey}_material`);
|
||||||
|
const savedSubtitleStyle = localStorage.getItem(`vigent_${storageKey}_subtitleStyle`);
|
||||||
|
const savedTitleStyle = localStorage.getItem(`vigent_${storageKey}_titleStyle`);
|
||||||
|
const savedSubtitleFontSize = localStorage.getItem(`vigent_${storageKey}_subtitleFontSize`);
|
||||||
|
const savedTitleFontSize = localStorage.getItem(`vigent_${storageKey}_titleFontSize`);
|
||||||
|
const savedBgmId = localStorage.getItem(`vigent_${storageKey}_bgmId`);
|
||||||
|
const savedSelectedVideoId = localStorage.getItem(`vigent_${storageKey}_selectedVideoId`);
|
||||||
|
const savedBgmVolume = localStorage.getItem(`vigent_${storageKey}_bgmVolume`);
|
||||||
|
const savedEnableBgm = localStorage.getItem(`vigent_${storageKey}_enableBgm`);
|
||||||
|
|
||||||
|
setText(savedText || "大家好,欢迎来到我的频道,今天给大家分享一些有趣的内容。");
|
||||||
|
setVideoTitle(savedTitle ? clampTitle(savedTitle) : "");
|
||||||
|
setEnableSubtitles(savedSubtitles !== null ? savedSubtitles === 'true' : true);
|
||||||
|
setTtsMode((savedTtsMode as 'edgetts' | 'voiceclone') || 'edgetts');
|
||||||
|
setVoice(savedVoice || "zh-CN-YunxiNeural");
|
||||||
|
|
||||||
|
if (savedMaterial) setSelectedMaterial(savedMaterial);
|
||||||
|
if (savedSubtitleStyle) setSelectedSubtitleStyleId(savedSubtitleStyle);
|
||||||
|
if (savedTitleStyle) setSelectedTitleStyleId(savedTitleStyle);
|
||||||
|
|
||||||
|
if (savedSubtitleFontSize) {
|
||||||
|
const parsed = parseInt(savedSubtitleFontSize, 10);
|
||||||
|
if (!Number.isNaN(parsed)) {
|
||||||
|
setSubtitleFontSize(parsed);
|
||||||
|
setSubtitleSizeLocked(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (savedTitleFontSize) {
|
||||||
|
const parsed = parseInt(savedTitleFontSize, 10);
|
||||||
|
if (!Number.isNaN(parsed)) {
|
||||||
|
setTitleFontSize(parsed);
|
||||||
|
setTitleSizeLocked(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (savedBgmId) setSelectedBgmId(savedBgmId);
|
||||||
|
if (savedBgmVolume) setBgmVolume(parseFloat(savedBgmVolume));
|
||||||
|
if (savedEnableBgm !== null) setEnableBgm(savedEnableBgm === 'true');
|
||||||
|
if (savedSelectedVideoId) setSelectedVideoId(savedSelectedVideoId);
|
||||||
|
|
||||||
|
setIsRestored(true);
|
||||||
|
}, [
|
||||||
|
isAuthLoading,
|
||||||
|
setBgmVolume,
|
||||||
|
setEnableBgm,
|
||||||
|
setEnableSubtitles,
|
||||||
|
setSelectedBgmId,
|
||||||
|
setSelectedMaterial,
|
||||||
|
setSelectedSubtitleStyleId,
|
||||||
|
setSelectedTitleStyleId,
|
||||||
|
setSelectedVideoId,
|
||||||
|
setSubtitleFontSize,
|
||||||
|
setSubtitleSizeLocked,
|
||||||
|
setText,
|
||||||
|
setTitleFontSize,
|
||||||
|
setTitleSizeLocked,
|
||||||
|
setTtsMode,
|
||||||
|
setVideoTitle,
|
||||||
|
setVoice,
|
||||||
|
storageKey,
|
||||||
|
]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isRestored) return;
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
localStorage.setItem(`vigent_${storageKey}_text`, text);
|
||||||
|
}, 300);
|
||||||
|
return () => clearTimeout(timeout);
|
||||||
|
}, [text, storageKey, isRestored]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isRestored) return;
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
localStorage.setItem(`vigent_${storageKey}_title`, videoTitle);
|
||||||
|
}, 300);
|
||||||
|
return () => clearTimeout(timeout);
|
||||||
|
}, [videoTitle, storageKey, isRestored]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isRestored) localStorage.setItem(`vigent_${storageKey}_subtitles`, String(enableSubtitles));
|
||||||
|
}, [enableSubtitles, storageKey, isRestored]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isRestored) localStorage.setItem(`vigent_${storageKey}_ttsMode`, ttsMode);
|
||||||
|
}, [ttsMode, storageKey, isRestored]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isRestored) localStorage.setItem(`vigent_${storageKey}_voice`, voice);
|
||||||
|
}, [voice, storageKey, isRestored]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isRestored && selectedMaterial) {
|
||||||
|
localStorage.setItem(`vigent_${storageKey}_material`, selectedMaterial);
|
||||||
|
}
|
||||||
|
}, [selectedMaterial, storageKey, isRestored]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isRestored && selectedSubtitleStyleId) {
|
||||||
|
localStorage.setItem(`vigent_${storageKey}_subtitleStyle`, selectedSubtitleStyleId);
|
||||||
|
}
|
||||||
|
}, [selectedSubtitleStyleId, storageKey, isRestored]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isRestored && selectedTitleStyleId) {
|
||||||
|
localStorage.setItem(`vigent_${storageKey}_titleStyle`, selectedTitleStyleId);
|
||||||
|
}
|
||||||
|
}, [selectedTitleStyleId, storageKey, isRestored]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isRestored) {
|
||||||
|
localStorage.setItem(`vigent_${storageKey}_subtitleFontSize`, String(subtitleFontSize));
|
||||||
|
}
|
||||||
|
}, [subtitleFontSize, storageKey, isRestored]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isRestored) {
|
||||||
|
localStorage.setItem(`vigent_${storageKey}_titleFontSize`, String(titleFontSize));
|
||||||
|
}
|
||||||
|
}, [titleFontSize, storageKey, isRestored]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isRestored) {
|
||||||
|
localStorage.setItem(`vigent_${storageKey}_bgmId`, selectedBgmId);
|
||||||
|
}
|
||||||
|
}, [selectedBgmId, storageKey, isRestored]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isRestored) return;
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
localStorage.setItem(`vigent_${storageKey}_bgmVolume`, String(bgmVolume));
|
||||||
|
}, 300);
|
||||||
|
return () => clearTimeout(timeout);
|
||||||
|
}, [bgmVolume, storageKey, isRestored]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isRestored) {
|
||||||
|
localStorage.setItem(`vigent_${storageKey}_enableBgm`, String(enableBgm));
|
||||||
|
}
|
||||||
|
}, [enableBgm, storageKey, isRestored]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isRestored) return;
|
||||||
|
if (selectedVideoId) {
|
||||||
|
localStorage.setItem(`vigent_${storageKey}_selectedVideoId`, selectedVideoId);
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem(`vigent_${storageKey}_selectedVideoId`);
|
||||||
|
}
|
||||||
|
}, [selectedVideoId, storageKey, isRestored]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isRestored && selectedRefAudio) {
|
||||||
|
localStorage.setItem(`vigent_${storageKey}_refAudioId`, selectedRefAudio.id);
|
||||||
|
}
|
||||||
|
}, [selectedRefAudio, storageKey, isRestored]);
|
||||||
|
|
||||||
|
return { isRestored };
|
||||||
|
};
|
||||||
113
frontend/src/hooks/useMaterials.ts
Normal file
113
frontend/src/hooks/useMaterials.ts
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import { useCallback, useState } from "react";
|
||||||
|
import api from "@/lib/axios";
|
||||||
|
|
||||||
|
interface Material {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
scene: string;
|
||||||
|
size_mb: number;
|
||||||
|
path: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseMaterialsOptions {
|
||||||
|
selectedMaterial: string;
|
||||||
|
setSelectedMaterial: React.Dispatch<React.SetStateAction<string>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useMaterials = ({
|
||||||
|
selectedMaterial,
|
||||||
|
setSelectedMaterial,
|
||||||
|
}: UseMaterialsOptions) => {
|
||||||
|
const [materials, setMaterials] = useState<Material[]>([]);
|
||||||
|
const [fetchError, setFetchError] = useState<string | null>(null);
|
||||||
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
|
const [uploadProgress, setUploadProgress] = useState(0);
|
||||||
|
const [uploadError, setUploadError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const fetchMaterials = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setFetchError(null);
|
||||||
|
|
||||||
|
const { data } = await api.get(`/api/materials?t=${new Date().getTime()}`);
|
||||||
|
const nextMaterials = data.materials || [];
|
||||||
|
setMaterials(nextMaterials);
|
||||||
|
|
||||||
|
const nextSelected = nextMaterials.find((item: Material) => item.id === selectedMaterial)?.id
|
||||||
|
|| nextMaterials[0]?.id
|
||||||
|
|| "";
|
||||||
|
if (nextSelected !== selectedMaterial) {
|
||||||
|
setSelectedMaterial(nextSelected);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("获取素材失败:", error);
|
||||||
|
setFetchError(String(error));
|
||||||
|
}
|
||||||
|
}, [selectedMaterial, setSelectedMaterial]);
|
||||||
|
|
||||||
|
const deleteMaterial = useCallback(async (materialId: string) => {
|
||||||
|
if (!confirm("确定要删除这个素材吗?")) return;
|
||||||
|
try {
|
||||||
|
await api.delete(`/api/materials/${materialId}`);
|
||||||
|
fetchMaterials();
|
||||||
|
if (selectedMaterial === materialId) {
|
||||||
|
setSelectedMaterial("");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
alert("删除失败: " + error);
|
||||||
|
}
|
||||||
|
}, [fetchMaterials, selectedMaterial, setSelectedMaterial]);
|
||||||
|
|
||||||
|
const handleUpload = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
const validTypes = ['.mp4', '.mov', '.avi'];
|
||||||
|
const ext = file.name.toLowerCase().slice(file.name.lastIndexOf('.'));
|
||||||
|
if (!validTypes.includes(ext)) {
|
||||||
|
setUploadError('仅支持 MP4、MOV、AVI 格式');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsUploading(true);
|
||||||
|
setUploadProgress(0);
|
||||||
|
setUploadError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
|
||||||
|
await api.post('/api/materials', formData, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
|
onUploadProgress: (progressEvent) => {
|
||||||
|
if (progressEvent.total) {
|
||||||
|
const progress = Math.round((progressEvent.loaded / progressEvent.total) * 100);
|
||||||
|
setUploadProgress(progress);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
setUploadProgress(100);
|
||||||
|
setIsUploading(false);
|
||||||
|
fetchMaterials();
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error("Upload failed:", err);
|
||||||
|
setIsUploading(false);
|
||||||
|
const errorMsg = err.response?.data?.detail || err.message || String(err);
|
||||||
|
setUploadError(`上传失败: ${errorMsg}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
e.target.value = '';
|
||||||
|
}, [fetchMaterials]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
materials,
|
||||||
|
fetchError,
|
||||||
|
isUploading,
|
||||||
|
uploadProgress,
|
||||||
|
uploadError,
|
||||||
|
setUploadError,
|
||||||
|
fetchMaterials,
|
||||||
|
deleteMaterial,
|
||||||
|
handleUpload,
|
||||||
|
};
|
||||||
|
};
|
||||||
116
frontend/src/hooks/useMediaPlayers.ts
Normal file
116
frontend/src/hooks/useMediaPlayers.ts
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import type { BgmItem } from "@/hooks/useBgm";
|
||||||
|
|
||||||
|
interface RefAudio {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
ref_text: string;
|
||||||
|
duration_sec: number;
|
||||||
|
created_at: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseMediaPlayersOptions {
|
||||||
|
bgmVolume: number;
|
||||||
|
resolveBgmUrl: (bgmId?: string | null) => string | null;
|
||||||
|
resolveMediaUrl: (url?: string | null) => string | null;
|
||||||
|
setSelectedBgmId: React.Dispatch<React.SetStateAction<string>>;
|
||||||
|
setEnableBgm: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useMediaPlayers = ({
|
||||||
|
bgmVolume,
|
||||||
|
resolveBgmUrl,
|
||||||
|
resolveMediaUrl,
|
||||||
|
setSelectedBgmId,
|
||||||
|
setEnableBgm,
|
||||||
|
}: UseMediaPlayersOptions) => {
|
||||||
|
const [playingAudioId, setPlayingAudioId] = useState<string | null>(null);
|
||||||
|
const [playingBgmId, setPlayingBgmId] = useState<string | null>(null);
|
||||||
|
const audioPlayerRef = useRef<HTMLAudioElement | null>(null);
|
||||||
|
const bgmPlayerRef = useRef<HTMLAudioElement | null>(null);
|
||||||
|
|
||||||
|
const stopAudio = useCallback(() => {
|
||||||
|
if (audioPlayerRef.current) {
|
||||||
|
audioPlayerRef.current.pause();
|
||||||
|
audioPlayerRef.current.currentTime = 0;
|
||||||
|
audioPlayerRef.current = null;
|
||||||
|
}
|
||||||
|
setPlayingAudioId(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const stopBgm = useCallback(() => {
|
||||||
|
if (bgmPlayerRef.current) {
|
||||||
|
bgmPlayerRef.current.pause();
|
||||||
|
bgmPlayerRef.current.currentTime = 0;
|
||||||
|
bgmPlayerRef.current = null;
|
||||||
|
}
|
||||||
|
setPlayingBgmId(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const togglePlayPreview = useCallback((audio: RefAudio, e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
if (bgmPlayerRef.current) {
|
||||||
|
stopBgm();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (playingAudioId === audio.id) {
|
||||||
|
stopAudio();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
stopAudio();
|
||||||
|
|
||||||
|
const audioUrl = resolveMediaUrl(audio.path) || audio.path;
|
||||||
|
if (!audioUrl) {
|
||||||
|
alert("无法播放该参考音频");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const player = new Audio(audioUrl);
|
||||||
|
player.onended = () => setPlayingAudioId(null);
|
||||||
|
player.play().catch((err) => alert("播放失败: " + err));
|
||||||
|
audioPlayerRef.current = player;
|
||||||
|
setPlayingAudioId(audio.id);
|
||||||
|
}, [playingAudioId, resolveMediaUrl, stopAudio, stopBgm]);
|
||||||
|
|
||||||
|
const toggleBgmPreview = useCallback((bgm: BgmItem, e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setSelectedBgmId(bgm.id);
|
||||||
|
setEnableBgm(true);
|
||||||
|
|
||||||
|
const bgmUrl = resolveBgmUrl(bgm.id);
|
||||||
|
if (!bgmUrl) {
|
||||||
|
alert("无法播放该背景音乐");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (playingBgmId === bgm.id) {
|
||||||
|
stopBgm();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
stopAudio();
|
||||||
|
stopBgm();
|
||||||
|
|
||||||
|
const player = new Audio(bgmUrl);
|
||||||
|
player.volume = Math.max(0, Math.min(bgmVolume, 1));
|
||||||
|
player.onended = () => setPlayingBgmId(null);
|
||||||
|
player.play().catch((err) => alert("播放失败: " + err));
|
||||||
|
bgmPlayerRef.current = player;
|
||||||
|
setPlayingBgmId(bgm.id);
|
||||||
|
}, [bgmVolume, playingBgmId, resolveBgmUrl, setEnableBgm, setSelectedBgmId, stopAudio, stopBgm]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (bgmPlayerRef.current) {
|
||||||
|
bgmPlayerRef.current.volume = Math.max(0, Math.min(bgmVolume, 1));
|
||||||
|
}
|
||||||
|
}, [bgmVolume]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
playingAudioId,
|
||||||
|
playingBgmId,
|
||||||
|
togglePlayPreview,
|
||||||
|
toggleBgmPreview,
|
||||||
|
};
|
||||||
|
};
|
||||||
91
frontend/src/hooks/useRefAudios.ts
Normal file
91
frontend/src/hooks/useRefAudios.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import { useCallback, useState } from "react";
|
||||||
|
import api from "@/lib/axios";
|
||||||
|
|
||||||
|
interface RefAudio {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
ref_text: string;
|
||||||
|
duration_sec: number;
|
||||||
|
created_at: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseRefAudiosOptions {
|
||||||
|
fixedRefText: string;
|
||||||
|
selectedRefAudio: RefAudio | null;
|
||||||
|
setSelectedRefAudio: React.Dispatch<React.SetStateAction<RefAudio | null>>;
|
||||||
|
setRefText: React.Dispatch<React.SetStateAction<string>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useRefAudios = ({
|
||||||
|
fixedRefText,
|
||||||
|
selectedRefAudio,
|
||||||
|
setSelectedRefAudio,
|
||||||
|
setRefText,
|
||||||
|
}: UseRefAudiosOptions) => {
|
||||||
|
const [refAudios, setRefAudios] = useState<RefAudio[]>([]);
|
||||||
|
const [isUploadingRef, setIsUploadingRef] = useState(false);
|
||||||
|
const [uploadRefError, setUploadRefError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const fetchRefAudios = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const { data } = await api.get('/api/ref-audios');
|
||||||
|
const items: RefAudio[] = data.items || [];
|
||||||
|
items.sort((a, b) => b.created_at - a.created_at);
|
||||||
|
setRefAudios(items);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("获取参考音频失败:", error);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const uploadRefAudio = useCallback(async (file: File) => {
|
||||||
|
const refTextInput = fixedRefText;
|
||||||
|
|
||||||
|
setIsUploadingRef(true);
|
||||||
|
setUploadRefError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
formData.append('ref_text', refTextInput);
|
||||||
|
|
||||||
|
const { data } = await api.post('/api/ref-audios', formData, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
|
});
|
||||||
|
|
||||||
|
await fetchRefAudios();
|
||||||
|
setSelectedRefAudio(data);
|
||||||
|
setRefText(data.ref_text);
|
||||||
|
setIsUploadingRef(false);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error("Upload ref audio failed:", err);
|
||||||
|
setIsUploadingRef(false);
|
||||||
|
const errorMsg = err.response?.data?.detail || err.message || String(err);
|
||||||
|
setUploadRefError(`上传失败: ${errorMsg}`);
|
||||||
|
}
|
||||||
|
}, [fetchRefAudios, fixedRefText, setRefText, setSelectedRefAudio]);
|
||||||
|
|
||||||
|
const deleteRefAudio = useCallback(async (audioId: string) => {
|
||||||
|
if (!confirm("确定要删除这个参考音频吗?")) return;
|
||||||
|
try {
|
||||||
|
await api.delete(`/api/ref-audios/${encodeURIComponent(audioId)}`);
|
||||||
|
fetchRefAudios();
|
||||||
|
if (selectedRefAudio?.id === audioId) {
|
||||||
|
setSelectedRefAudio(null);
|
||||||
|
setRefText('');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
alert("删除失败: " + error);
|
||||||
|
}
|
||||||
|
}, [fetchRefAudios, selectedRefAudio, setRefText, setSelectedRefAudio]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
refAudios,
|
||||||
|
isUploadingRef,
|
||||||
|
uploadRefError,
|
||||||
|
setUploadRefError,
|
||||||
|
fetchRefAudios,
|
||||||
|
uploadRefAudio,
|
||||||
|
deleteRefAudio,
|
||||||
|
};
|
||||||
|
};
|
||||||
66
frontend/src/hooks/useTitleInput.ts
Normal file
66
frontend/src/hooks/useTitleInput.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { useCallback, useEffect, useRef } from "react";
|
||||||
|
import { applyTitleLimit, TITLE_MAX_LENGTH } from "@/lib/title";
|
||||||
|
|
||||||
|
interface UseTitleInputOptions {
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
onCommit?: (value: string) => void;
|
||||||
|
maxLength?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useTitleInput = ({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
onCommit,
|
||||||
|
maxLength = TITLE_MAX_LENGTH,
|
||||||
|
}: UseTitleInputOptions) => {
|
||||||
|
const isComposingRef = useRef(false);
|
||||||
|
const committedRef = useRef(value);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isComposingRef.current) return;
|
||||||
|
committedRef.current = value;
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
const commitValue = useCallback(
|
||||||
|
(nextValue: string) => {
|
||||||
|
committedRef.current = nextValue;
|
||||||
|
onChange(nextValue);
|
||||||
|
onCommit?.(nextValue);
|
||||||
|
},
|
||||||
|
[onChange, onCommit]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleChange = useCallback(
|
||||||
|
(nextValue: string) => {
|
||||||
|
if (isComposingRef.current) {
|
||||||
|
onChange(nextValue);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const limited = applyTitleLimit(committedRef.current, nextValue, maxLength);
|
||||||
|
commitValue(limited);
|
||||||
|
},
|
||||||
|
[maxLength, onChange, commitValue]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCompositionStart = useCallback(() => {
|
||||||
|
isComposingRef.current = true;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCompositionEnd = useCallback(
|
||||||
|
(nextValue: string) => {
|
||||||
|
isComposingRef.current = false;
|
||||||
|
const limited = applyTitleLimit(committedRef.current, nextValue, maxLength);
|
||||||
|
commitValue(limited);
|
||||||
|
},
|
||||||
|
[maxLength, commitValue]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
handleChange,
|
||||||
|
handleCompositionStart,
|
||||||
|
handleCompositionEnd,
|
||||||
|
commitValue,
|
||||||
|
maxLength,
|
||||||
|
};
|
||||||
|
};
|
||||||
98
frontend/src/hooks/useTitleSubtitleStyles.ts
Normal file
98
frontend/src/hooks/useTitleSubtitleStyles.ts
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import api from "@/lib/axios";
|
||||||
|
|
||||||
|
export interface SubtitleStyleOption {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
font_family?: string;
|
||||||
|
font_file?: string;
|
||||||
|
font_size?: number;
|
||||||
|
highlight_color?: string;
|
||||||
|
normal_color?: string;
|
||||||
|
stroke_color?: string;
|
||||||
|
stroke_size?: number;
|
||||||
|
letter_spacing?: number;
|
||||||
|
bottom_margin?: number;
|
||||||
|
is_default?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TitleStyleOption {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
font_family?: string;
|
||||||
|
font_file?: string;
|
||||||
|
font_size?: number;
|
||||||
|
color?: string;
|
||||||
|
stroke_color?: string;
|
||||||
|
stroke_size?: number;
|
||||||
|
letter_spacing?: number;
|
||||||
|
font_weight?: number;
|
||||||
|
top_margin?: number;
|
||||||
|
is_default?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseTitleSubtitleStylesOptions {
|
||||||
|
isAuthLoading: boolean;
|
||||||
|
storageKey: string;
|
||||||
|
setSelectedSubtitleStyleId: React.Dispatch<React.SetStateAction<string>>;
|
||||||
|
setSelectedTitleStyleId: React.Dispatch<React.SetStateAction<string>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useTitleSubtitleStyles = ({
|
||||||
|
isAuthLoading,
|
||||||
|
storageKey,
|
||||||
|
setSelectedSubtitleStyleId,
|
||||||
|
setSelectedTitleStyleId,
|
||||||
|
}: UseTitleSubtitleStylesOptions) => {
|
||||||
|
const [subtitleStyles, setSubtitleStyles] = useState<SubtitleStyleOption[]>([]);
|
||||||
|
const [titleStyles, setTitleStyles] = useState<TitleStyleOption[]>([]);
|
||||||
|
|
||||||
|
const refreshSubtitleStyles = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const { data } = await api.get('/api/assets/subtitle-styles');
|
||||||
|
const styles: SubtitleStyleOption[] = data.styles || [];
|
||||||
|
setSubtitleStyles(styles);
|
||||||
|
|
||||||
|
const savedStyleId = localStorage.getItem(`vigent_${storageKey}_subtitleStyle`);
|
||||||
|
setSelectedSubtitleStyleId((prev) => {
|
||||||
|
if (prev && styles.some((s) => s.id === prev)) return prev;
|
||||||
|
if (savedStyleId && styles.some((s) => s.id === savedStyleId)) return savedStyleId;
|
||||||
|
const defaultStyle = styles.find((s) => s.is_default) || styles[0];
|
||||||
|
return defaultStyle?.id || "";
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("获取字幕样式失败:", error);
|
||||||
|
}
|
||||||
|
}, [setSelectedSubtitleStyleId, storageKey]);
|
||||||
|
|
||||||
|
const refreshTitleStyles = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const { data } = await api.get('/api/assets/title-styles');
|
||||||
|
const styles: TitleStyleOption[] = data.styles || [];
|
||||||
|
setTitleStyles(styles);
|
||||||
|
|
||||||
|
const savedStyleId = localStorage.getItem(`vigent_${storageKey}_titleStyle`);
|
||||||
|
setSelectedTitleStyleId((prev) => {
|
||||||
|
if (prev && styles.some((s) => s.id === prev)) return prev;
|
||||||
|
if (savedStyleId && styles.some((s) => s.id === savedStyleId)) return savedStyleId;
|
||||||
|
const defaultStyle = styles.find((s) => s.is_default) || styles[0];
|
||||||
|
return defaultStyle?.id || "";
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("获取标题样式失败:", error);
|
||||||
|
}
|
||||||
|
}, [setSelectedTitleStyleId, storageKey]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isAuthLoading) return;
|
||||||
|
refreshSubtitleStyles();
|
||||||
|
refreshTitleStyles();
|
||||||
|
}, [isAuthLoading, refreshSubtitleStyles, refreshTitleStyles]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
subtitleStyles,
|
||||||
|
titleStyles,
|
||||||
|
refreshSubtitleStyles,
|
||||||
|
refreshTitleStyles,
|
||||||
|
};
|
||||||
|
};
|
||||||
14
frontend/src/lib/title.ts
Normal file
14
frontend/src/lib/title.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
export const TITLE_MAX_LENGTH = 15;
|
||||||
|
|
||||||
|
export const clampTitle = (value: string, maxLength: number = TITLE_MAX_LENGTH) =>
|
||||||
|
value.slice(0, maxLength);
|
||||||
|
|
||||||
|
export const applyTitleLimit = (
|
||||||
|
prev: string,
|
||||||
|
next: string,
|
||||||
|
maxLength: number = TITLE_MAX_LENGTH
|
||||||
|
) => {
|
||||||
|
if (next.length <= maxLength) return next;
|
||||||
|
if (prev.length >= maxLength) return prev;
|
||||||
|
return next.slice(0, maxLength);
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user