更新
This commit is contained in:
@@ -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)
|
||||
|
||||
### 内容
|
||||
|
||||
@@ -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 读写。
|
||||
- 如需新增持久化字段,必须加入恢复与保存逻辑,并更新本节。
|
||||
|
||||
---
|
||||
|
||||
@@ -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 新增]
|
||||
|
||||
@@ -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。
|
||||
|
||||
@@ -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 流水线并发。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ export default function RootLayout({
|
||||
>
|
||||
<AuthProvider>
|
||||
<TaskProvider>
|
||||
<GlobalTaskIndicator />
|
||||
{children}
|
||||
</TaskProvider>
|
||||
</AuthProvider>
|
||||
|
||||
@@ -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<Material[]>([]);
|
||||
const [selectedMaterial, setSelectedMaterial] = useState<string>("");
|
||||
const [previewMaterial, setPreviewMaterial] = useState<string | null>(null);
|
||||
|
||||
@@ -139,21 +110,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 +126,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 +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<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()) {
|
||||
|
||||
@@ -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() {
|
||||
<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="预览"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import api from "@/lib/axios";
|
||||
|
||||
interface ScriptExtractionModalProps {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
55
frontend/src/hooks/useBgm.ts
Normal file
55
frontend/src/hooks/useBgm.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import api from "@/lib/axios";
|
||||
|
||||
export interface BgmItem {
|
||||
id: string;
|
||||
name: string;
|
||||
ext?: string;
|
||||
}
|
||||
|
||||
interface UseBgmOptions {
|
||||
storageKey: string;
|
||||
selectedBgmId: string;
|
||||
setSelectedBgmId: React.Dispatch<React.SetStateAction<string>>;
|
||||
}
|
||||
|
||||
export const useBgm = ({
|
||||
storageKey,
|
||||
selectedBgmId,
|
||||
setSelectedBgmId,
|
||||
}: UseBgmOptions) => {
|
||||
const [bgmList, setBgmList] = useState<BgmItem[]>([]);
|
||||
const [bgmLoading, setBgmLoading] = useState(false);
|
||||
const [bgmError, setBgmError] = useState<string>("");
|
||||
|
||||
const fetchBgmList = useCallback(async () => {
|
||||
setBgmLoading(true);
|
||||
setBgmError("");
|
||||
try {
|
||||
const { data } = await api.get('/api/assets/bgm');
|
||||
const items: BgmItem[] = Array.isArray(data.bgm) ? data.bgm : [];
|
||||
setBgmList(items);
|
||||
|
||||
const savedBgmId = localStorage.getItem(`vigent_${storageKey}_bgmId`);
|
||||
setSelectedBgmId((prev) => {
|
||||
if (prev && items.some((item) => item.id === prev)) return prev;
|
||||
if (savedBgmId && items.some((item) => item.id === savedBgmId)) return savedBgmId;
|
||||
return items[0]?.id || "";
|
||||
});
|
||||
} catch (error: any) {
|
||||
const message = error?.response?.data?.detail || error?.message || '加载失败';
|
||||
setBgmError(message);
|
||||
setBgmList([]);
|
||||
console.error("获取背景音乐失败:", error);
|
||||
} finally {
|
||||
setBgmLoading(false);
|
||||
}
|
||||
}, [setSelectedBgmId, storageKey]);
|
||||
|
||||
return {
|
||||
bgmList,
|
||||
bgmLoading,
|
||||
bgmError,
|
||||
fetchBgmList,
|
||||
};
|
||||
};
|
||||
81
frontend/src/hooks/useGeneratedVideos.ts
Normal file
81
frontend/src/hooks/useGeneratedVideos.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import api from "@/lib/axios";
|
||||
|
||||
interface GeneratedVideo {
|
||||
id: string;
|
||||
name: string;
|
||||
path: string;
|
||||
size_mb: number;
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
interface UseGeneratedVideosOptions {
|
||||
storageKey: string;
|
||||
selectedVideoId: string | null;
|
||||
setSelectedVideoId: React.Dispatch<React.SetStateAction<string | null>>;
|
||||
setGeneratedVideo: React.Dispatch<React.SetStateAction<string | null>>;
|
||||
resolveMediaUrl: (url?: string | null) => string | null;
|
||||
}
|
||||
|
||||
export const useGeneratedVideos = ({
|
||||
storageKey,
|
||||
selectedVideoId,
|
||||
setSelectedVideoId,
|
||||
setGeneratedVideo,
|
||||
resolveMediaUrl,
|
||||
}: UseGeneratedVideosOptions) => {
|
||||
const [generatedVideos, setGeneratedVideos] = useState<GeneratedVideo[]>([]);
|
||||
|
||||
const fetchGeneratedVideos = useCallback(async (preferVideoId?: string) => {
|
||||
try {
|
||||
const { data } = await api.get('/api/videos/generated');
|
||||
const videos: GeneratedVideo[] = data.videos || [];
|
||||
setGeneratedVideos(videos);
|
||||
|
||||
const savedSelectedVideoId = localStorage.getItem(`vigent_${storageKey}_selectedVideoId`);
|
||||
const currentId = preferVideoId || selectedVideoId || savedSelectedVideoId || null;
|
||||
let nextId: string | null = null;
|
||||
let nextUrl: string | null = null;
|
||||
|
||||
if (currentId) {
|
||||
const found = videos.find(v => v.id === currentId);
|
||||
if (found) {
|
||||
nextId = found.id;
|
||||
nextUrl = resolveMediaUrl(found.path);
|
||||
}
|
||||
}
|
||||
|
||||
if (!nextId && videos.length > 0) {
|
||||
nextId = videos[0].id;
|
||||
nextUrl = resolveMediaUrl(videos[0].path);
|
||||
}
|
||||
|
||||
if (nextId) {
|
||||
setSelectedVideoId(nextId);
|
||||
setGeneratedVideo(nextUrl);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("获取历史视频失败:", error);
|
||||
}
|
||||
}, [resolveMediaUrl, selectedVideoId, setGeneratedVideo, setSelectedVideoId, storageKey]);
|
||||
|
||||
const deleteVideo = useCallback(async (videoId: string) => {
|
||||
if (!confirm("确定要删除这个视频吗?")) return;
|
||||
try {
|
||||
await api.delete(`/api/videos/generated/${videoId}`);
|
||||
if (selectedVideoId === videoId) {
|
||||
setSelectedVideoId(null);
|
||||
setGeneratedVideo(null);
|
||||
}
|
||||
fetchGeneratedVideos();
|
||||
} catch (error) {
|
||||
alert("删除失败: " + error);
|
||||
}
|
||||
}, [fetchGeneratedVideos, selectedVideoId, setGeneratedVideo, setSelectedVideoId]);
|
||||
|
||||
return {
|
||||
generatedVideos,
|
||||
fetchGeneratedVideos,
|
||||
deleteVideo,
|
||||
};
|
||||
};
|
||||
250
frontend/src/hooks/useHomePersistence.ts
Normal file
250
frontend/src/hooks/useHomePersistence.ts
Normal file
@@ -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<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 || "");
|
||||
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 };
|
||||
};
|
||||
121
frontend/src/hooks/useMaterials.ts
Normal file
121
frontend/src/hooks/useMaterials.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import api from "@/lib/axios";
|
||||
|
||||
interface Material {
|
||||
id: string;
|
||||
name: string;
|
||||
scene: string;
|
||||
size_mb: number;
|
||||
path: string;
|
||||
}
|
||||
|
||||
interface UseMaterialsOptions {
|
||||
selectedMaterial: string;
|
||||
setSelectedMaterial: React.Dispatch<React.SetStateAction<string>>;
|
||||
}
|
||||
|
||||
export const useMaterials = ({
|
||||
selectedMaterial,
|
||||
setSelectedMaterial,
|
||||
}: UseMaterialsOptions) => {
|
||||
const [materials, setMaterials] = useState<Material[]>([]);
|
||||
const [fetchError, setFetchError] = useState<string | null>(null);
|
||||
const [debugData, setDebugData] = useState<string>("");
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [uploadProgress, setUploadProgress] = useState(0);
|
||||
const [uploadError, setUploadError] = useState<string | null>(null);
|
||||
const [uploadData, setUploadData] = useState<string>("");
|
||||
|
||||
const fetchMaterials = useCallback(async () => {
|
||||
try {
|
||||
setFetchError(null);
|
||||
setDebugData("Loading...");
|
||||
|
||||
const { data } = await api.get(`/api/materials?t=${new Date().getTime()}`);
|
||||
setDebugData(JSON.stringify(data).substring(0, 200));
|
||||
const nextMaterials = data.materials || [];
|
||||
setMaterials(nextMaterials);
|
||||
|
||||
const nextSelected = nextMaterials.find((item: Material) => item.id === selectedMaterial)?.id
|
||||
|| nextMaterials[0]?.id
|
||||
|| "";
|
||||
if (nextSelected !== selectedMaterial) {
|
||||
setSelectedMaterial(nextSelected);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("获取素材失败:", error);
|
||||
setFetchError(String(error));
|
||||
setDebugData(`Error: ${String(error)}`);
|
||||
}
|
||||
}, [selectedMaterial, setSelectedMaterial]);
|
||||
|
||||
const deleteMaterial = useCallback(async (materialId: string) => {
|
||||
if (!confirm("确定要删除这个素材吗?")) return;
|
||||
try {
|
||||
await api.delete(`/api/materials/${materialId}`);
|
||||
fetchMaterials();
|
||||
if (selectedMaterial === materialId) {
|
||||
setSelectedMaterial("");
|
||||
}
|
||||
} catch (error) {
|
||||
alert("删除失败: " + error);
|
||||
}
|
||||
}, [fetchMaterials, selectedMaterial, setSelectedMaterial]);
|
||||
|
||||
const handleUpload = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const validTypes = ['.mp4', '.mov', '.avi'];
|
||||
const ext = file.name.toLowerCase().slice(file.name.lastIndexOf('.'));
|
||||
if (!validTypes.includes(ext)) {
|
||||
setUploadError('仅支持 MP4、MOV、AVI 格式');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsUploading(true);
|
||||
setUploadProgress(0);
|
||||
setUploadError(null);
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
await api.post('/api/materials', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
onUploadProgress: (progressEvent) => {
|
||||
if (progressEvent.total) {
|
||||
const progress = Math.round((progressEvent.loaded / progressEvent.total) * 100);
|
||||
setUploadProgress(progress);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
setUploadProgress(100);
|
||||
setIsUploading(false);
|
||||
fetchMaterials();
|
||||
setUploadData("");
|
||||
} catch (err: any) {
|
||||
console.error("Upload failed:", err);
|
||||
setIsUploading(false);
|
||||
const errorMsg = err.response?.data?.detail || err.message || String(err);
|
||||
setUploadError(`上传失败: ${errorMsg}`);
|
||||
}
|
||||
|
||||
e.target.value = '';
|
||||
}, [fetchMaterials]);
|
||||
|
||||
return {
|
||||
materials,
|
||||
fetchError,
|
||||
debugData,
|
||||
isUploading,
|
||||
uploadProgress,
|
||||
uploadError,
|
||||
uploadData,
|
||||
setUploadError,
|
||||
fetchMaterials,
|
||||
deleteMaterial,
|
||||
handleUpload,
|
||||
};
|
||||
};
|
||||
116
frontend/src/hooks/useMediaPlayers.ts
Normal file
116
frontend/src/hooks/useMediaPlayers.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { BgmItem } from "@/hooks/useBgm";
|
||||
|
||||
interface RefAudio {
|
||||
id: string;
|
||||
name: string;
|
||||
path: string;
|
||||
ref_text: string;
|
||||
duration_sec: number;
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
interface UseMediaPlayersOptions {
|
||||
bgmVolume: number;
|
||||
resolveBgmUrl: (bgmId?: string | null) => string | null;
|
||||
resolveMediaUrl: (url?: string | null) => string | null;
|
||||
setSelectedBgmId: React.Dispatch<React.SetStateAction<string>>;
|
||||
setEnableBgm: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
}
|
||||
|
||||
export const useMediaPlayers = ({
|
||||
bgmVolume,
|
||||
resolveBgmUrl,
|
||||
resolveMediaUrl,
|
||||
setSelectedBgmId,
|
||||
setEnableBgm,
|
||||
}: UseMediaPlayersOptions) => {
|
||||
const [playingAudioId, setPlayingAudioId] = useState<string | null>(null);
|
||||
const [playingBgmId, setPlayingBgmId] = useState<string | null>(null);
|
||||
const audioPlayerRef = useRef<HTMLAudioElement | null>(null);
|
||||
const bgmPlayerRef = useRef<HTMLAudioElement | null>(null);
|
||||
|
||||
const stopAudio = useCallback(() => {
|
||||
if (audioPlayerRef.current) {
|
||||
audioPlayerRef.current.pause();
|
||||
audioPlayerRef.current.currentTime = 0;
|
||||
audioPlayerRef.current = null;
|
||||
}
|
||||
setPlayingAudioId(null);
|
||||
}, []);
|
||||
|
||||
const stopBgm = useCallback(() => {
|
||||
if (bgmPlayerRef.current) {
|
||||
bgmPlayerRef.current.pause();
|
||||
bgmPlayerRef.current.currentTime = 0;
|
||||
bgmPlayerRef.current = null;
|
||||
}
|
||||
setPlayingBgmId(null);
|
||||
}, []);
|
||||
|
||||
const togglePlayPreview = useCallback((audio: RefAudio, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
|
||||
if (bgmPlayerRef.current) {
|
||||
stopBgm();
|
||||
}
|
||||
|
||||
if (playingAudioId === audio.id) {
|
||||
stopAudio();
|
||||
return;
|
||||
}
|
||||
|
||||
stopAudio();
|
||||
|
||||
const audioUrl = resolveMediaUrl(audio.path) || audio.path;
|
||||
if (!audioUrl) {
|
||||
alert("无法播放该参考音频");
|
||||
return;
|
||||
}
|
||||
const player = new Audio(audioUrl);
|
||||
player.onended = () => setPlayingAudioId(null);
|
||||
player.play().catch((err) => alert("播放失败: " + err));
|
||||
audioPlayerRef.current = player;
|
||||
setPlayingAudioId(audio.id);
|
||||
}, [playingAudioId, resolveMediaUrl, stopAudio, stopBgm]);
|
||||
|
||||
const toggleBgmPreview = useCallback((bgm: BgmItem, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setSelectedBgmId(bgm.id);
|
||||
setEnableBgm(true);
|
||||
|
||||
const bgmUrl = resolveBgmUrl(bgm.id);
|
||||
if (!bgmUrl) {
|
||||
alert("无法播放该背景音乐");
|
||||
return;
|
||||
}
|
||||
|
||||
if (playingBgmId === bgm.id) {
|
||||
stopBgm();
|
||||
return;
|
||||
}
|
||||
|
||||
stopAudio();
|
||||
stopBgm();
|
||||
|
||||
const player = new Audio(bgmUrl);
|
||||
player.volume = Math.max(0, Math.min(bgmVolume, 1));
|
||||
player.onended = () => setPlayingBgmId(null);
|
||||
player.play().catch((err) => alert("播放失败: " + err));
|
||||
bgmPlayerRef.current = player;
|
||||
setPlayingBgmId(bgm.id);
|
||||
}, [bgmVolume, playingBgmId, resolveBgmUrl, setEnableBgm, setSelectedBgmId, stopAudio, stopBgm]);
|
||||
|
||||
useEffect(() => {
|
||||
if (bgmPlayerRef.current) {
|
||||
bgmPlayerRef.current.volume = Math.max(0, Math.min(bgmVolume, 1));
|
||||
}
|
||||
}, [bgmVolume]);
|
||||
|
||||
return {
|
||||
playingAudioId,
|
||||
playingBgmId,
|
||||
togglePlayPreview,
|
||||
toggleBgmPreview,
|
||||
};
|
||||
};
|
||||
91
frontend/src/hooks/useRefAudios.ts
Normal file
91
frontend/src/hooks/useRefAudios.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import api from "@/lib/axios";
|
||||
|
||||
interface RefAudio {
|
||||
id: string;
|
||||
name: string;
|
||||
path: string;
|
||||
ref_text: string;
|
||||
duration_sec: number;
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
interface UseRefAudiosOptions {
|
||||
fixedRefText: string;
|
||||
selectedRefAudio: RefAudio | null;
|
||||
setSelectedRefAudio: React.Dispatch<React.SetStateAction<RefAudio | null>>;
|
||||
setRefText: React.Dispatch<React.SetStateAction<string>>;
|
||||
}
|
||||
|
||||
export const useRefAudios = ({
|
||||
fixedRefText,
|
||||
selectedRefAudio,
|
||||
setSelectedRefAudio,
|
||||
setRefText,
|
||||
}: UseRefAudiosOptions) => {
|
||||
const [refAudios, setRefAudios] = useState<RefAudio[]>([]);
|
||||
const [isUploadingRef, setIsUploadingRef] = useState(false);
|
||||
const [uploadRefError, setUploadRefError] = useState<string | null>(null);
|
||||
|
||||
const fetchRefAudios = useCallback(async () => {
|
||||
try {
|
||||
const { data } = await api.get('/api/ref-audios');
|
||||
const items: RefAudio[] = data.items || [];
|
||||
items.sort((a, b) => b.created_at - a.created_at);
|
||||
setRefAudios(items);
|
||||
} catch (error) {
|
||||
console.error("获取参考音频失败:", error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const uploadRefAudio = useCallback(async (file: File) => {
|
||||
const refTextInput = fixedRefText;
|
||||
|
||||
setIsUploadingRef(true);
|
||||
setUploadRefError(null);
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('ref_text', refTextInput);
|
||||
|
||||
const { data } = await api.post('/api/ref-audios', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
|
||||
await fetchRefAudios();
|
||||
setSelectedRefAudio(data);
|
||||
setRefText(data.ref_text);
|
||||
setIsUploadingRef(false);
|
||||
} catch (err: any) {
|
||||
console.error("Upload ref audio failed:", err);
|
||||
setIsUploadingRef(false);
|
||||
const errorMsg = err.response?.data?.detail || err.message || String(err);
|
||||
setUploadRefError(`上传失败: ${errorMsg}`);
|
||||
}
|
||||
}, [fetchRefAudios, fixedRefText, setRefText, setSelectedRefAudio]);
|
||||
|
||||
const deleteRefAudio = useCallback(async (audioId: string) => {
|
||||
if (!confirm("确定要删除这个参考音频吗?")) return;
|
||||
try {
|
||||
await api.delete(`/api/ref-audios/${encodeURIComponent(audioId)}`);
|
||||
fetchRefAudios();
|
||||
if (selectedRefAudio?.id === audioId) {
|
||||
setSelectedRefAudio(null);
|
||||
setRefText('');
|
||||
}
|
||||
} catch (error) {
|
||||
alert("删除失败: " + error);
|
||||
}
|
||||
}, [fetchRefAudios, selectedRefAudio, setRefText, setSelectedRefAudio]);
|
||||
|
||||
return {
|
||||
refAudios,
|
||||
isUploadingRef,
|
||||
uploadRefError,
|
||||
setUploadRefError,
|
||||
fetchRefAudios,
|
||||
uploadRefAudio,
|
||||
deleteRefAudio,
|
||||
};
|
||||
};
|
||||
98
frontend/src/hooks/useTitleSubtitleStyles.ts
Normal file
98
frontend/src/hooks/useTitleSubtitleStyles.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import api from "@/lib/axios";
|
||||
|
||||
export interface SubtitleStyleOption {
|
||||
id: string;
|
||||
label: string;
|
||||
font_family?: string;
|
||||
font_file?: string;
|
||||
font_size?: number;
|
||||
highlight_color?: string;
|
||||
normal_color?: string;
|
||||
stroke_color?: string;
|
||||
stroke_size?: number;
|
||||
letter_spacing?: number;
|
||||
bottom_margin?: number;
|
||||
is_default?: boolean;
|
||||
}
|
||||
|
||||
export interface TitleStyleOption {
|
||||
id: string;
|
||||
label: string;
|
||||
font_family?: string;
|
||||
font_file?: string;
|
||||
font_size?: number;
|
||||
color?: string;
|
||||
stroke_color?: string;
|
||||
stroke_size?: number;
|
||||
letter_spacing?: number;
|
||||
font_weight?: number;
|
||||
top_margin?: number;
|
||||
is_default?: boolean;
|
||||
}
|
||||
|
||||
interface UseTitleSubtitleStylesOptions {
|
||||
isAuthLoading: boolean;
|
||||
storageKey: string;
|
||||
setSelectedSubtitleStyleId: React.Dispatch<React.SetStateAction<string>>;
|
||||
setSelectedTitleStyleId: React.Dispatch<React.SetStateAction<string>>;
|
||||
}
|
||||
|
||||
export const useTitleSubtitleStyles = ({
|
||||
isAuthLoading,
|
||||
storageKey,
|
||||
setSelectedSubtitleStyleId,
|
||||
setSelectedTitleStyleId,
|
||||
}: UseTitleSubtitleStylesOptions) => {
|
||||
const [subtitleStyles, setSubtitleStyles] = useState<SubtitleStyleOption[]>([]);
|
||||
const [titleStyles, setTitleStyles] = useState<TitleStyleOption[]>([]);
|
||||
|
||||
const refreshSubtitleStyles = useCallback(async () => {
|
||||
try {
|
||||
const { data } = await api.get('/api/assets/subtitle-styles');
|
||||
const styles: SubtitleStyleOption[] = data.styles || [];
|
||||
setSubtitleStyles(styles);
|
||||
|
||||
const savedStyleId = localStorage.getItem(`vigent_${storageKey}_subtitleStyle`);
|
||||
setSelectedSubtitleStyleId((prev) => {
|
||||
if (prev && styles.some((s) => s.id === prev)) return prev;
|
||||
if (savedStyleId && styles.some((s) => s.id === savedStyleId)) return savedStyleId;
|
||||
const defaultStyle = styles.find((s) => s.is_default) || styles[0];
|
||||
return defaultStyle?.id || "";
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("获取字幕样式失败:", error);
|
||||
}
|
||||
}, [setSelectedSubtitleStyleId, storageKey]);
|
||||
|
||||
const refreshTitleStyles = useCallback(async () => {
|
||||
try {
|
||||
const { data } = await api.get('/api/assets/title-styles');
|
||||
const styles: TitleStyleOption[] = data.styles || [];
|
||||
setTitleStyles(styles);
|
||||
|
||||
const savedStyleId = localStorage.getItem(`vigent_${storageKey}_titleStyle`);
|
||||
setSelectedTitleStyleId((prev) => {
|
||||
if (prev && styles.some((s) => s.id === prev)) return prev;
|
||||
if (savedStyleId && styles.some((s) => s.id === savedStyleId)) return savedStyleId;
|
||||
const defaultStyle = styles.find((s) => s.is_default) || styles[0];
|
||||
return defaultStyle?.id || "";
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("获取标题样式失败:", error);
|
||||
}
|
||||
}, [setSelectedTitleStyleId, storageKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthLoading) return;
|
||||
refreshSubtitleStyles();
|
||||
refreshTitleStyles();
|
||||
}, [isAuthLoading, refreshSubtitleStyles, refreshTitleStyles]);
|
||||
|
||||
return {
|
||||
subtitleStyles,
|
||||
titleStyles,
|
||||
refreshSubtitleStyles,
|
||||
refreshTitleStyles,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user