Compare commits

..

4 Commits

Author SHA1 Message Date
Kevin Wong
aaa8088c82 更新 2026-02-04 17:19:24 +08:00
Kevin Wong
31469ca01d 更新 2026-02-04 16:56:16 +08:00
Kevin Wong
22ea3dd0db 更新 2026-02-04 16:54:59 +08:00
Kevin Wong
8a5912c517 更新 2026-02-04 15:59:45 +08:00
26 changed files with 1225 additions and 623 deletions

View File

@@ -1,4 +1,5 @@
## 🧩 前端 UI 拆分 (11:00)
# Day 17 - 前端重构与体验优化
## 🧩 前端 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`

View File

@@ -167,6 +167,7 @@ const timeText = formatDate(video.created_at);
### 资源路径规则
- 视频/音频:优先用 `resolveMediaUrl()`
- 字体/BGM使用 `resolveAssetUrl()`(自动编码中文路径)
- 预览前若已有签名 URL先用 `isAbsoluteUrl()` 判定,避免再次拼接
---
@@ -214,10 +215,20 @@ import { formatDate } from '@/lib/media';
- 使用 `storageKey = userId || 'guest'`,按用户隔离。
- **恢复先于保存**:恢复完成前禁止写入(`isRestored` 保护)。
- 避免默认值覆盖用户选择(优先读取已保存值)。
- 优先使用 `useHomePersistence` 集中管理恢复/保存,页面内避免分散的 localStorage 读写。
- 如需新增持久化字段,必须加入恢复与保存逻辑,并更新本节。
---
## 标题输入规则
- 片头标题与发布信息标题统一限制 15 字。
- 中文输入法合成阶段不截断,合成结束后才校验长度。
- 首页片头标题修改会同步写入 `vigent_${storageKey}_publish_title`
- 避免使用 `maxLength` 强制截断输入法合成态。
---
## 新增页面 Checklist
1. [ ] 导入 `import api from '@/lib/axios'`

View File

@@ -14,7 +14,7 @@ ViGent2 的前端界面,采用 Next.js 16 + TailwindCSS 构建。
- **预览一致性**: 标题/字幕预览按素材分辨率缩放,效果更接近成片 (Day 17)。
- **进度追踪**: 实时显示视频生成进度 (10% -> 100%)。
- **作品预览**: 生成完成后直接播放下载(作品预览 + 历史作品)。
- **本地保存**: 文案/标题自动保存,刷新后恢复 (Day 14)。
- **本地保存**: 文案/标题/偏好由 `useHomePersistence` 统一持久化,刷新后恢复 (Day 14/17)。
### 2. 全自动发布 (`/publish`) [Day 7 新增]
- **多平台管理**: 统一管理 B站、抖音、小红书账号状态。
@@ -24,6 +24,7 @@ ViGent2 的前端界面,采用 Next.js 16 + TailwindCSS 构建。
- Cookie 自动保存与状态同步。
- **发布配置**: 设置视频标题、标签、简介。
- **作品选择**: 卡片列表 + 搜索 + 预览弹窗。
- **预览兼容**: 签名 URL / 相对路径均可直接预览。
- **定时任务**: 支持 "立即发布" 或 "定时发布"。
### 3. 声音克隆 [Day 13 新增]
@@ -32,7 +33,8 @@ ViGent2 的前端界面,采用 Next.js 16 + TailwindCSS 构建。
- **一键克隆**: 选择参考音频后自动调用 Qwen3-TTS 服务。
### 4. 字幕与标题 [Day 13 新增]
- **片头标题**: 可选输入,视频开头显示 3 秒淡入淡出标题。
- **片头标题**: 可选输入,限制 15 字,视频开头显示 3 秒淡入淡出标题。
- **标题同步**: 首页片头标题修改会同步到发布信息标题。
- **逐字高亮字幕**: 卡拉OK效果默认开启可关闭。
- **自动对齐**: 基于 faster-whisper 生成字级别时间戳。
- **样式预设**: 标题/字幕样式选择 + 预览 + 字号调节 (Day 16)。

View File

@@ -140,7 +140,7 @@ backend/
| 端点 | 方法 | 功能 |
|------|------|------|
| `/api/materials` | POST | 上传素材视频 | ✅ |
| `/api/materials` | POST | 上传视频素材 | ✅ |
| `/api/materials` | GET | 获取素材列表 | ✅ |
| `/api/videos/generate` | POST | 创建视频生成任务 | ✅ |
| `/api/videos/tasks/{id}` | GET | 查询任务状态 | ✅ |
@@ -164,7 +164,7 @@ background_tasks.add_task(_process_video_generation, task_id, req, user_id)
| 页面 | 功能 |
|------|------|
| **素材库** | 上传/管理多场景素材视频 |
| **素材库** | 上传/管理多场景视频素材 |
| **生成视频** | 输入文案、选择素材、生成预览 |
| **任务中心** | 查看生成进度、下载视频 |
| **发布管理** | 绑定平台、一键发布、定时发布 |

View File

@@ -21,6 +21,9 @@
- [x] **性能微优化**: 列表渲染优化 + 并行请求 + localStorage 防抖。
- [x] **资源能力**: 字体/BGM 资源库 + `/api/assets` 接入。
- [x] **音频与字幕修复**: BGM 混音稳定性与字幕断句优化。
- [x] **持久化修复**: 接入 `useHomePersistence`,恢复 `isRestored` 逻辑并通过构建。
- [x] **预览与选择修复**: 发布预览兼容签名 URL音频试听路径解析素材/BGM 回退有效项。
- [x] **体验细节优化**: 录音预览 URL 回收,预览弹窗滚动恢复,全局任务提示挂载。
### Day 16: 深度性能优化
- [x] **Qwen-TTS 加速**: 集成 Flash Attention 2模型加载速度提升至 8.9s。

View File

@@ -21,14 +21,16 @@
- 📝 **智能字幕** - 集成 faster-whisper + Remotion自动生成逐字高亮 (卡拉OK效果) 字幕。
- 🎨 **样式预设** - 标题/字幕样式选择 + 预览 + 字号调节,支持自定义字体库。
- 🖼️ **作品预览一致性** - 标题/字幕预览按素材分辨率缩放,效果更接近成片。
- 💾 **用户偏好持久化** - 首页状态统一恢复/保存,刷新后延续上次配置。
- 🎵 **背景音乐** - 试听 + 音量控制 + 混音,保持配音音量稳定。
- 🤖 **AI 辅助创作** - 内置 GLM-4.7-Flash支持 B站/抖音链接文案提取、AI 洗稿、标题/标签自动生成。
### 平台化功能
- 📱 **全自动发布** - 支持 B站、抖音、小红书定时发布扫码登录 + Cookie 持久化。
- 🔐 **企业级认证** - 完善的用户隔离系统 (Supabase),支持手机号注册/登录、密码管理
- 📱 **全自动发布** - 支持 B站、抖音、小红书定时发布扫码登录 + Cookie 持久化。
- 🖥️ **发布管理预览** - 支持签名 URL / 相对路径作品预览,确保可直接播放
- 🔐 **认证与隔离** - 基于 Supabase 的用户隔离,支持手机号注册/登录、密码管理。
- 🛡️ **服务守护** - 内置 Watchdog 看门狗机制,自动监控并重启僵死服务,确保 7x24h 稳定运行。
- 🚀 **极致性能** - 视频预压缩、模型常驻服务 (0s加载)、双 GPU 流水线并发。
- 🚀 **性能优化** - 视频预压缩、模型常驻服务(近实时加载、双 GPU 流水线并发。
---

View File

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

View File

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

View File

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

View File

@@ -12,6 +12,15 @@ import {
buildTextShadow,
formatDate,
} 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 { useTask } from "@/contexts/TaskContext";
import VideoPreviewModal from "@/components/VideoPreviewModal";
@@ -88,47 +97,11 @@ interface RefAudio {
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() {
const [materials, setMaterials] = useState<Material[]>([]);
const [selectedMaterial, setSelectedMaterial] = useState<string>("");
const [previewMaterial, setPreviewMaterial] = useState<string | null>(null);
@@ -139,21 +112,11 @@ export default function Home() {
const { currentTask, isGenerating, startTask } = useTask();
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 [videoTitle, setVideoTitle] = useState<string>("");
const [enableSubtitles, setEnableSubtitles] = useState<boolean>(true);
const [subtitleStyles, setSubtitleStyles] = useState<SubtitleStyleOption[]>([]);
const [titleStyles, setTitleStyles] = useState<TitleStyleOption[]>([]);
const [selectedSubtitleStyleId, setSelectedSubtitleStyleId] = useState<string>("");
const [selectedTitleStyleId, setSelectedTitleStyleId] = useState<string>("");
const [subtitleFontSize, setSubtitleFontSize] = useState<number>(60);
@@ -165,107 +128,24 @@ export default function Home() {
const [previewContainerWidth, setPreviewContainerWidth] = useState<number>(0);
// 背景音乐相关状态
const [bgmList, setBgmList] = useState<BgmItem[]>([]);
const [selectedBgmId, setSelectedBgmId] = useState<string>("");
const [enableBgm, setEnableBgm] = useState<boolean>(false);
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 [refAudios, setRefAudios] = useState<RefAudio[]>([]);
const [selectedRefAudio, setSelectedRefAudio] = useState<RefAudio | null>(null);
const [refText, setRefText] = useState('其实生活中有许多美好的瞬间,比如清晨的阳光,或者一杯温热的清茶。希望这次生成的音色能够自然、流畅,完美还原出我最真实的声音状态。');
const [isUploadingRef, setIsUploadingRef] = useState(false);
const [uploadRefError, setUploadRefError] = useState<string | null>(null);
// 音频预览与重命名状态
const [editingAudioId, setEditingAudioId] = useState<string | null>(null);
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 bgmListContainerRef = useRef<HTMLDivElement | null>(null);
const titlePreviewContainerRef = useRef<HTMLDivElement | null>(null);
const materialItemRefs = 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) => {
e.stopPropagation();
@@ -306,8 +186,6 @@ export default function Home() {
// 使用全局认证状态
const { userId, isLoading: isAuthLoading } = useAuth();
// 是否已从 localStorage 恢复完成
const [isRestored, setIsRestored] = useState(false);
// 文案提取模态框
const [extractModalOpen, setExtractModalOpen] = useState(false);
@@ -317,6 +195,132 @@ export default function Home() {
// 获取存储 key 的前缀(登录用户使用 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(() => {
if (isAuthLoading) return;
@@ -324,8 +328,8 @@ export default function Home() {
fetchMaterials(),
fetchGeneratedVideos(),
fetchRefAudios(),
fetchSubtitleStyles(),
fetchTitleStyles(),
refreshSubtitleStyles(),
refreshTitleStyles(),
fetchBgmList(),
]);
}, [isAuthLoading]);
@@ -407,261 +411,11 @@ export default function Home() {
}
if (completedVideoId) {
setSelectedVideoId(completedVideoId);
localStorage.setItem(`vigent_${storageKey}_selectedVideoId`, completedVideoId);
}
fetchGeneratedVideos(completedVideoId || undefined); // 刷新历史视频列表
}
}, [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(() => {
if (subtitleSizeLocked || subtitleStyles.length === 0) return;
const active = subtitleStyles.find(s => s.id === selectedSubtitleStyleId)
@@ -682,34 +436,6 @@ export default function Home() {
}
}, [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(() => {
if (!enableBgm || selectedBgmId || bgmList.length === 0) return;
const savedBgmId = localStorage.getItem(`vigent_${storageKey}_bgmId`);
@@ -721,12 +447,6 @@ export default function Home() {
setSelectedBgmId(bgmList[0].id);
}, [enableBgm, selectedBgmId, bgmList, storageKey]);
useEffect(() => {
if (bgmPlayerRef.current) {
bgmPlayerRef.current.volume = Math.max(0, Math.min(bgmVolume, 1));
}
}, [bgmVolume]);
useEffect(() => {
if (!selectedBgmId) return;
const container = bgmListContainerRef.current;
@@ -783,49 +503,6 @@ export default function Home() {
}
}, [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 () => {
try {
@@ -901,11 +578,11 @@ export default function Home() {
console.log("[Home] AI生成结果:", data);
// 更新首页标题
setVideoTitle(data.title || "");
const nextTitle = clampTitle(data.title || "");
titleInput.commitValue(nextTitle);
// 同步到发布页 localStorage
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 || []));
} 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 () => {
if (!selectedMaterial || !text.trim()) {
@@ -1121,7 +720,9 @@ export default function Home() {
showStylePreview={showStylePreview}
onTogglePreview={() => setShowStylePreview((prev) => !prev)}
videoTitle={videoTitle}
onTitleChange={setVideoTitle}
onTitleChange={titleInput.handleChange}
onTitleCompositionStart={titleInput.handleCompositionStart}
onTitleCompositionEnd={titleInput.handleCompositionEnd}
titleStyles={titleStyles}
selectedTitleStyleId={selectedTitleStyleId}
onSelectTitleStyle={setSelectedTitleStyleId}

View File

@@ -4,10 +4,12 @@ import { useState, useEffect, useMemo } from "react";
import useSWR from 'swr';
import Link from "next/link";
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 AccountSettingsDropdown from "@/components/AccountSettingsDropdown";
import VideoPreviewModal from "@/components/VideoPreviewModal";
import { useTitleInput } from "@/hooks/useTitleInput";
import {
ArrowLeft,
RotateCcw,
@@ -38,16 +40,16 @@ interface Video {
path: string;
}
export default function PublishPage() {
export default function PublishPage() {
const [accounts, setAccounts] = useState<Account[]>([]);
const [videos, setVideos] = useState<Video[]>([]);
const [selectedVideo, setSelectedVideo] = useState<string>("");
const [videoFilter, setVideoFilter] = useState<string>("");
const [previewVideoUrl, setPreviewVideoUrl] = useState<string | null>(null);
const [selectedPlatforms, setSelectedPlatforms] = useState<string[]>([]);
const [title, setTitle] = useState<string>("");
const [tags, setTags] = useState<string>("");
const [isPublishing, setIsPublishing] = useState(false);
const [selectedPlatforms, setSelectedPlatforms] = useState<string[]>([]);
const [title, setTitle] = useState<string>("");
const [tags, setTags] = useState<string>("");
const [isPublishing, setIsPublishing] = useState(false);
const [publishResults, setPublishResults] = useState<any[]>([]);
const [scheduleMode, setScheduleMode] = useState<"now" | "scheduled">("now");
const [publishTime, setPublishTime] = useState<string>("");
@@ -57,8 +59,13 @@ export default function PublishPage() {
// 使用全局认证状态
const { userId, isLoading: isAuthLoading } = useAuth();
// 是否已从 localStorage 恢复完成
const [isRestored, setIsRestored] = useState(false);
// 是否已从 localStorage 恢复完成
const [isRestored, setIsRestored] = useState(false);
const titleInput = useTitleInput({
value: title,
onChange: setTitle,
});
// 加载账号和视频列表
useEffect(() => {
@@ -81,17 +88,13 @@ export default function PublishPage() {
// 从 localStorage 恢复用户输入(等待认证完成后)
useEffect(() => {
console.log("[Publish] 恢复检查 - isAuthLoading:", isAuthLoading, "userId:", userId);
if (isAuthLoading) return;
console.log("[Publish] 开始从 localStorage 恢复数据storageKey:", storageKey);
// 从 localStorage 恢复用户输入(带用户隔离,未登录用户使用 guest
const savedTitle = localStorage.getItem(`vigent_${storageKey}_publish_title`);
const savedTags = localStorage.getItem(`vigent_${storageKey}_publish_tags`);
console.log("[Publish] localStorage 数据:", { savedTitle, savedTags });
if (savedTitle) setTitle(savedTitle);
if (isAuthLoading) return;
// 从 localStorage 恢复用户输入(带用户隔离,未登录用户使用 guest
const savedTitle = localStorage.getItem(`vigent_${storageKey}_publish_title`);
const savedTags = localStorage.getItem(`vigent_${storageKey}_publish_tags`);
if (savedTitle) setTitle(clampTitle(savedTitle));
if (savedTags) {
// 兼容 JSON 数组格式AI 生成)和字符串格式(手动输入)
try {
@@ -106,10 +109,9 @@ export default function PublishPage() {
}
}
// 恢复完成后才允许保存
setIsRestored(true);
console.log("[Publish] 恢复完成isRestored = true");
}, [storageKey, isAuthLoading]);
// 恢复完成后才允许保存
setIsRestored(true);
}, [storageKey, isAuthLoading]);
// 保存用户输入到 localStorage恢复完成后才保存未登录用户也可保存
useEffect(() => {
@@ -288,7 +290,7 @@ export default function PublishPage() {
return videos.filter((v) => v.name.toLowerCase().includes(query));
}, [videos, videoFilter]);
return (
return (
<div className="min-h-dvh">
<VideoPreviewModal
onClose={() => setPreviewVideoUrl(null)}
@@ -482,7 +484,12 @@ export default function PublishPage() {
<button
onClick={(e) => {
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"
title="预览"
@@ -510,13 +517,15 @@ export default function PublishPage() {
<label className="block text-gray-400 text-sm mb-2">
</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="输入视频标题..."
className="w-full p-3 bg-black/30 border border-white/10 rounded-xl text-white placeholder-gray-500"
/>
<input
type="text"
value={title}
onChange={(e) => titleInput.handleChange(e.target.value)}
onCompositionStart={titleInput.handleCompositionStart}
onCompositionEnd={(e) => titleInput.handleCompositionEnd(e.currentTarget.value)}
placeholder="输入视频标题..."
className="w-full p-3 bg-black/30 border border-white/10 rounded-xl text-white placeholder-gray-500"
/>
</div>
<div>
<label className="block text-gray-400 text-sm mb-2">
@@ -658,5 +667,5 @@ export default function PublishPage() {
</div>
</main>
</div>
);
}
);
}

View File

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

View File

@@ -16,21 +16,22 @@ export default function VideoPreviewModal({
title = "视频预览",
subtitle = "ESC 关闭 · 点击空白关闭",
}: VideoPreviewModalProps) {
useEffect(() => {
// 按 ESC 关闭
const handleEsc = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
if (videoUrl) {
document.addEventListener('keydown', handleEsc);
// 禁止背景滚动
document.body.style.overflow = 'hidden';
}
return () => {
document.removeEventListener('keydown', handleEsc);
document.body.style.overflow = 'unset';
};
}, [videoUrl, onClose]);
useEffect(() => {
if (!videoUrl) return;
// 按 ESC 关闭
const handleEsc = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
const prevOverflow = document.body.style.overflow;
document.addEventListener('keydown', handleEsc);
// 禁止背景滚动
document.body.style.overflow = 'hidden';
return () => {
document.removeEventListener('keydown', handleEsc);
document.body.style.overflow = prevOverflow;
};
}, [videoUrl, onClose]);
if (!videoUrl) return null;

View File

@@ -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="flex justify-between items-center gap-2 mb-4">
<h2 className="text-base sm:text-lg font-semibold text-white flex items-center gap-2 whitespace-nowrap">
📹
📹
<span className="ml-1 text-[11px] sm:text-xs text-gray-400/90 font-normal">
()
</span>
@@ -112,9 +112,9 @@ export function MaterialSelector({
) : materials.length === 0 ? (
<div className="text-center py-8 text-gray-400">
<div className="text-5xl mb-4">📁</div>
<p></p>
<p></p>
<p className="text-sm mt-2">
📤
📤
</p>
</div>
) : (

View File

@@ -1,3 +1,4 @@
import { useEffect, useState } from "react";
import type { MouseEvent } from "react";
import { Upload, RefreshCw, Play, Pause, Pencil, Trash2, Check, X, Mic, Square } from "lucide-react";
@@ -65,6 +66,20 @@ export function RefAudioPanel({
formatRecordingTime,
fixedRefText,
}: RefAudioPanelProps) {
const [recordedUrl, setRecordedUrl] = useState<string | null>(null);
useEffect(() => {
if (!recordedBlob) {
setRecordedUrl(null);
return;
}
const url = URL.createObjectURL(recordedBlob);
setRecordedUrl(url);
return () => {
URL.revokeObjectURL(url);
};
}, [recordedBlob]);
return (
<div className="space-y-4">
<div>
@@ -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="flex items-center gap-2 mb-2">
<span className="text-green-300 text-sm"> ({formatRecordingTime(recordingTime)})</span>
<audio src={URL.createObjectURL(recordedBlob)} controls className="h-8" />
<audio src={recordedUrl || ''} controls className="h-8" />
</div>
<button
onClick={onUseRecording}

View File

@@ -36,6 +36,8 @@ interface TitleSubtitlePanelProps {
onTogglePreview: () => void;
videoTitle: string;
onTitleChange: (value: string) => void;
onTitleCompositionStart?: () => void;
onTitleCompositionEnd?: (value: string) => void;
titleStyles: TitleStyleOption[];
selectedTitleStyleId: string;
onSelectTitleStyle: (id: string) => void;
@@ -63,6 +65,8 @@ export function TitleSubtitlePanel({
onTogglePreview,
videoTitle,
onTitleChange,
onTitleCompositionStart,
onTitleCompositionEnd,
titleStyles,
selectedTitleStyleId,
onSelectTitleStyle,
@@ -209,11 +213,13 @@ export function TitleSubtitlePanel({
)}
<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
type="text"
value={videoTitle}
onChange={(e) => onTitleChange(e.target.value)}
onCompositionStart={onTitleCompositionStart}
onCompositionEnd={(e) => onTitleCompositionEnd?.(e.currentTarget.value)}
placeholder="输入视频标题,将在片头显示"
className="w-full px-3 sm:px-4 py-2 text-sm sm:text-base bg-black/30 border border-white/10 rounded-xl text-white placeholder-gray-500 focus:outline-none focus:border-purple-500 transition-colors"
/>

View File

@@ -26,7 +26,7 @@ export function VoiceSelector({
return (
<div className="bg-white/5 rounded-2xl p-6 border border-white/10 backdrop-blur-sm">
<h2 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
🎙
🎙
</h2>
<div className="flex gap-2 mb-4">

View File

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

View File

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

View File

@@ -0,0 +1,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 };
};

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

View File

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

View File

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

View File

@@ -0,0 +1,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,
};
};

View File

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

14
frontend/src/lib/title.ts Normal file
View 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);
};