diff --git a/Docs/DevLogs/Day17.md b/Docs/DevLogs/Day17.md index aac864d..1d71b30 100644 --- a/Docs/DevLogs/Day17.md +++ b/Docs/DevLogs/Day17.md @@ -1,3 +1,34 @@ +## 🧩 发布预览与播放修复 (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` + +--- + +## 🧩 首页持久化修复 (12:20) + +### 内容 +- 接入 `useHomePersistence`,补齐 `isRestored` 恢复/保存逻辑 +- 修复首页刷新后选择项恢复链路,`npm run build` 通过 + +### 涉及文件 +- `frontend/src/app/page.tsx` +- `frontend/src/hooks/useHomePersistence.ts` + +--- + ## 🧩 前端 UI 拆分 (11:00) ## 🧩 前端 UI 拆分 (09:10) @@ -65,6 +96,27 @@ --- +## 🧱 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` + +--- + ## 🖼️ 预览弹窗增强 (11:10) ### 内容 diff --git a/Docs/FRONTEND_DEV.md b/Docs/FRONTEND_DEV.md index cf94553..54ebb61 100644 --- a/Docs/FRONTEND_DEV.md +++ b/Docs/FRONTEND_DEV.md @@ -167,6 +167,7 @@ const timeText = formatDate(video.created_at); ### 资源路径规则 - 视频/音频:优先用 `resolveMediaUrl()` - 字体/BGM:使用 `resolveAssetUrl()`(自动编码中文路径) +- 预览前若已有签名 URL,先用 `isAbsoluteUrl()` 判定,避免再次拼接 --- @@ -214,6 +215,7 @@ import { formatDate } from '@/lib/media'; - 使用 `storageKey = userId || 'guest'`,按用户隔离。 - **恢复先于保存**:恢复完成前禁止写入(`isRestored` 保护)。 - 避免默认值覆盖用户选择(优先读取已保存值)。 +- 优先使用 `useHomePersistence` 集中管理恢复/保存,页面内避免分散的 localStorage 读写。 - 如需新增持久化字段,必须加入恢复与保存逻辑,并更新本节。 --- diff --git a/Docs/FRONTEND_README.md b/Docs/FRONTEND_README.md index 688bb6a..cd458c0 100644 --- a/Docs/FRONTEND_README.md +++ b/Docs/FRONTEND_README.md @@ -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 新增] diff --git a/Docs/task_complete.md b/Docs/task_complete.md index 3c1df61..8e85e9c 100644 --- a/Docs/task_complete.md +++ b/Docs/task_complete.md @@ -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。 diff --git a/README.md b/README.md index 84cef4e..f70354e 100644 --- a/README.md +++ b/README.md @@ -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 流水线并发。 --- diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index 20a3dc2..e0ca062 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -39,6 +39,7 @@ export default function RootLayout({ > + {children} diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 1f1bc8e..79b64e1 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -12,6 +12,13 @@ import { buildTextShadow, formatDate, } from "@/lib/media"; +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 { useAuth } from "@/contexts/AuthContext"; import { useTask } from "@/contexts/TaskContext"; import VideoPreviewModal from "@/components/VideoPreviewModal"; @@ -88,47 +95,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([]); const [selectedMaterial, setSelectedMaterial] = useState(""); const [previewMaterial, setPreviewMaterial] = useState(null); @@ -139,21 +110,11 @@ export default function Home() { const { currentTask, isGenerating, startTask } = useTask(); const [generatedVideo, setGeneratedVideo] = useState(null); - const [fetchError, setFetchError] = useState(null); - const [debugData, setDebugData] = useState(""); - const [isUploading, setIsUploading] = useState(false); - const [uploadProgress, setUploadProgress] = useState(0); - const [uploadError, setUploadError] = useState(null); - const [uploadData, setUploadData] = useState(""); - const [generatedVideos, setGeneratedVideos] = useState([]); - const [selectedVideoId, setSelectedVideoId] = useState(null); // 字幕和标题相关状态 const [videoTitle, setVideoTitle] = useState(""); const [enableSubtitles, setEnableSubtitles] = useState(true); - const [subtitleStyles, setSubtitleStyles] = useState([]); - const [titleStyles, setTitleStyles] = useState([]); const [selectedSubtitleStyleId, setSelectedSubtitleStyleId] = useState(""); const [selectedTitleStyleId, setSelectedTitleStyleId] = useState(""); const [subtitleFontSize, setSubtitleFontSize] = useState(60); @@ -165,107 +126,24 @@ export default function Home() { const [previewContainerWidth, setPreviewContainerWidth] = useState(0); // 背景音乐相关状态 - const [bgmList, setBgmList] = useState([]); const [selectedBgmId, setSelectedBgmId] = useState(""); const [enableBgm, setEnableBgm] = useState(false); const [bgmVolume, setBgmVolume] = useState(0.2); - const [playingBgmId, setPlayingBgmId] = useState(null); - const [bgmLoading, setBgmLoading] = useState(false); - const [bgmError, setBgmError] = useState(""); // 声音克隆相关状态 const [ttsMode, setTtsMode] = useState<'edgetts' | 'voiceclone'>('edgetts'); - const [refAudios, setRefAudios] = useState([]); const [selectedRefAudio, setSelectedRefAudio] = useState(null); const [refText, setRefText] = useState('其实生活中有许多美好的瞬间,比如清晨的阳光,或者一杯温热的清茶。希望这次生成的音色能够自然、流畅,完美还原出我最真实的声音状态。'); - const [isUploadingRef, setIsUploadingRef] = useState(false); - const [uploadRefError, setUploadRefError] = useState(null); // 音频预览与重命名状态 const [editingAudioId, setEditingAudioId] = useState(null); const [editName, setEditName] = useState(""); - const [playingAudioId, setPlayingAudioId] = useState(null); - const audioPlayerRef = useRef(null); - const bgmPlayerRef = useRef(null); const bgmItemRefs = useRef>({}); const bgmListContainerRef = useRef(null); const titlePreviewContainerRef = useRef(null); const materialItemRefs = useRef>({}); const videoItemRefs = useRef>({}); - // 播放/暂停预览 - 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 +184,6 @@ export default function Home() { // 使用全局认证状态 const { userId, isLoading: isAuthLoading } = useAuth(); - // 是否已从 localStorage 恢复完成 - const [isRestored, setIsRestored] = useState(false); // 文案提取模态框 const [extractModalOpen, setExtractModalOpen] = useState(false); @@ -317,6 +193,120 @@ 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, + }); + // 加载素材列表和历史视频 useEffect(() => { if (isAuthLoading) return; @@ -324,8 +314,8 @@ export default function Home() { fetchMaterials(), fetchGeneratedVideos(), fetchRefAudios(), - fetchSubtitleStyles(), - fetchTitleStyles(), + refreshSubtitleStyles(), + refreshTitleStyles(), fetchBgmList(), ]); }, [isAuthLoading]); @@ -407,261 +397,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 +422,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 +433,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 +489,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 { @@ -917,84 +580,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) => { - 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()) { diff --git a/frontend/src/app/publish/page.tsx b/frontend/src/app/publish/page.tsx index c919e17..967c19f 100644 --- a/frontend/src/app/publish/page.tsx +++ b/frontend/src/app/publish/page.tsx @@ -4,7 +4,7 @@ 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 { useAuth } from "@/contexts/AuthContext"; import AccountSettingsDropdown from "@/components/AccountSettingsDropdown"; import VideoPreviewModal from "@/components/VideoPreviewModal"; @@ -482,7 +482,12 @@ export default function PublishPage() { { 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="预览" diff --git a/frontend/src/components/ScriptExtractionModal.tsx b/frontend/src/components/ScriptExtractionModal.tsx index aa24b90..5980af3 100644 --- a/frontend/src/components/ScriptExtractionModal.tsx +++ b/frontend/src/components/ScriptExtractionModal.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useRef, useEffect } from "react"; +import { useState, useEffect } from "react"; import api from "@/lib/axios"; interface ScriptExtractionModalProps { diff --git a/frontend/src/components/VideoPreviewModal.tsx b/frontend/src/components/VideoPreviewModal.tsx index 995507d..793815f 100644 --- a/frontend/src/components/VideoPreviewModal.tsx +++ b/frontend/src/components/VideoPreviewModal.tsx @@ -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; diff --git a/frontend/src/components/home/RefAudioPanel.tsx b/frontend/src/components/home/RefAudioPanel.tsx index f429999..b4305d3 100644 --- a/frontend/src/components/home/RefAudioPanel.tsx +++ b/frontend/src/components/home/RefAudioPanel.tsx @@ -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(null); + + useEffect(() => { + if (!recordedBlob) { + setRecordedUrl(null); + return; + } + const url = URL.createObjectURL(recordedBlob); + setRecordedUrl(url); + return () => { + URL.revokeObjectURL(url); + }; + }, [recordedBlob]); + return ( @@ -235,7 +250,7 @@ export function RefAudioPanel({ ✅ 录音完成 ({formatRecordingTime(recordingTime)}) - + >; +} + +export const useBgm = ({ + storageKey, + selectedBgmId, + setSelectedBgmId, +}: UseBgmOptions) => { + const [bgmList, setBgmList] = useState([]); + const [bgmLoading, setBgmLoading] = useState(false); + const [bgmError, setBgmError] = useState(""); + + 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, + }; +}; diff --git a/frontend/src/hooks/useGeneratedVideos.ts b/frontend/src/hooks/useGeneratedVideos.ts new file mode 100644 index 0000000..9560fa6 --- /dev/null +++ b/frontend/src/hooks/useGeneratedVideos.ts @@ -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>; + setGeneratedVideo: React.Dispatch>; + resolveMediaUrl: (url?: string | null) => string | null; +} + +export const useGeneratedVideos = ({ + storageKey, + selectedVideoId, + setSelectedVideoId, + setGeneratedVideo, + resolveMediaUrl, +}: UseGeneratedVideosOptions) => { + const [generatedVideos, setGeneratedVideos] = useState([]); + + 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, + }; +}; diff --git a/frontend/src/hooks/useHomePersistence.ts b/frontend/src/hooks/useHomePersistence.ts new file mode 100644 index 0000000..0d14fff --- /dev/null +++ b/frontend/src/hooks/useHomePersistence.ts @@ -0,0 +1,250 @@ +import { useEffect, useState } from "react"; + +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>; + videoTitle: string; + setVideoTitle: React.Dispatch>; + enableSubtitles: boolean; + setEnableSubtitles: React.Dispatch>; + ttsMode: 'edgetts' | 'voiceclone'; + setTtsMode: React.Dispatch>; + voice: string; + setVoice: React.Dispatch>; + selectedMaterial: string; + setSelectedMaterial: React.Dispatch>; + selectedSubtitleStyleId: string; + setSelectedSubtitleStyleId: React.Dispatch>; + selectedTitleStyleId: string; + setSelectedTitleStyleId: React.Dispatch>; + subtitleFontSize: number; + setSubtitleFontSize: React.Dispatch>; + titleFontSize: number; + setTitleFontSize: React.Dispatch>; + setSubtitleSizeLocked: React.Dispatch>; + setTitleSizeLocked: React.Dispatch>; + selectedBgmId: string; + setSelectedBgmId: React.Dispatch>; + bgmVolume: number; + setBgmVolume: React.Dispatch>; + enableBgm: boolean; + setEnableBgm: React.Dispatch>; + selectedVideoId: string | null; + setSelectedVideoId: React.Dispatch>; + 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 || ""); + 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 }; +}; diff --git a/frontend/src/hooks/useMaterials.ts b/frontend/src/hooks/useMaterials.ts new file mode 100644 index 0000000..7c76b98 --- /dev/null +++ b/frontend/src/hooks/useMaterials.ts @@ -0,0 +1,121 @@ +import { useCallback, useState } from "react"; +import api from "@/lib/axios"; + +interface Material { + id: string; + name: string; + scene: string; + size_mb: number; + path: string; +} + +interface UseMaterialsOptions { + selectedMaterial: string; + setSelectedMaterial: React.Dispatch>; +} + +export const useMaterials = ({ + selectedMaterial, + setSelectedMaterial, +}: UseMaterialsOptions) => { + const [materials, setMaterials] = useState([]); + const [fetchError, setFetchError] = useState(null); + const [debugData, setDebugData] = useState(""); + const [isUploading, setIsUploading] = useState(false); + const [uploadProgress, setUploadProgress] = useState(0); + const [uploadError, setUploadError] = useState(null); + const [uploadData, setUploadData] = useState(""); + + const fetchMaterials = useCallback(async () => { + try { + setFetchError(null); + setDebugData("Loading..."); + + const { data } = await api.get(`/api/materials?t=${new Date().getTime()}`); + setDebugData(JSON.stringify(data).substring(0, 200)); + const nextMaterials = data.materials || []; + setMaterials(nextMaterials); + + const nextSelected = nextMaterials.find((item: Material) => item.id === selectedMaterial)?.id + || nextMaterials[0]?.id + || ""; + if (nextSelected !== selectedMaterial) { + setSelectedMaterial(nextSelected); + } + } catch (error) { + console.error("获取素材失败:", error); + setFetchError(String(error)); + setDebugData(`Error: ${String(error)}`); + } + }, [selectedMaterial, setSelectedMaterial]); + + const deleteMaterial = useCallback(async (materialId: string) => { + if (!confirm("确定要删除这个素材吗?")) return; + try { + await api.delete(`/api/materials/${materialId}`); + fetchMaterials(); + if (selectedMaterial === materialId) { + setSelectedMaterial(""); + } + } catch (error) { + alert("删除失败: " + error); + } + }, [fetchMaterials, selectedMaterial, setSelectedMaterial]); + + const handleUpload = useCallback(async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + const validTypes = ['.mp4', '.mov', '.avi']; + const ext = file.name.toLowerCase().slice(file.name.lastIndexOf('.')); + if (!validTypes.includes(ext)) { + setUploadError('仅支持 MP4、MOV、AVI 格式'); + return; + } + + setIsUploading(true); + setUploadProgress(0); + setUploadError(null); + + try { + const formData = new FormData(); + formData.append('file', file); + + await api.post('/api/materials', formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + onUploadProgress: (progressEvent) => { + if (progressEvent.total) { + const progress = Math.round((progressEvent.loaded / progressEvent.total) * 100); + setUploadProgress(progress); + } + }, + }); + + setUploadProgress(100); + setIsUploading(false); + fetchMaterials(); + setUploadData(""); + } catch (err: any) { + console.error("Upload failed:", err); + setIsUploading(false); + const errorMsg = err.response?.data?.detail || err.message || String(err); + setUploadError(`上传失败: ${errorMsg}`); + } + + e.target.value = ''; + }, [fetchMaterials]); + + return { + materials, + fetchError, + debugData, + isUploading, + uploadProgress, + uploadError, + uploadData, + setUploadError, + fetchMaterials, + deleteMaterial, + handleUpload, + }; +}; diff --git a/frontend/src/hooks/useMediaPlayers.ts b/frontend/src/hooks/useMediaPlayers.ts new file mode 100644 index 0000000..9bd7f90 --- /dev/null +++ b/frontend/src/hooks/useMediaPlayers.ts @@ -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>; + setEnableBgm: React.Dispatch>; +} + +export const useMediaPlayers = ({ + bgmVolume, + resolveBgmUrl, + resolveMediaUrl, + setSelectedBgmId, + setEnableBgm, +}: UseMediaPlayersOptions) => { + const [playingAudioId, setPlayingAudioId] = useState(null); + const [playingBgmId, setPlayingBgmId] = useState(null); + const audioPlayerRef = useRef(null); + const bgmPlayerRef = useRef(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, + }; +}; diff --git a/frontend/src/hooks/useRefAudios.ts b/frontend/src/hooks/useRefAudios.ts new file mode 100644 index 0000000..fb70fce --- /dev/null +++ b/frontend/src/hooks/useRefAudios.ts @@ -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>; + setRefText: React.Dispatch>; +} + +export const useRefAudios = ({ + fixedRefText, + selectedRefAudio, + setSelectedRefAudio, + setRefText, +}: UseRefAudiosOptions) => { + const [refAudios, setRefAudios] = useState([]); + const [isUploadingRef, setIsUploadingRef] = useState(false); + const [uploadRefError, setUploadRefError] = useState(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, + }; +}; diff --git a/frontend/src/hooks/useTitleSubtitleStyles.ts b/frontend/src/hooks/useTitleSubtitleStyles.ts new file mode 100644 index 0000000..79328f0 --- /dev/null +++ b/frontend/src/hooks/useTitleSubtitleStyles.ts @@ -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>; + setSelectedTitleStyleId: React.Dispatch>; +} + +export const useTitleSubtitleStyles = ({ + isAuthLoading, + storageKey, + setSelectedSubtitleStyleId, + setSelectedTitleStyleId, +}: UseTitleSubtitleStylesOptions) => { + const [subtitleStyles, setSubtitleStyles] = useState([]); + const [titleStyles, setTitleStyles] = useState([]); + + 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, + }; +};