This commit is contained in:
Kevin Wong
2026-02-04 15:59:45 +08:00
parent 74516dbcdb
commit 8a5912c517
18 changed files with 1040 additions and 561 deletions

View File

@@ -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)
### 内容

View File

@@ -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 读写。
- 如需新增持久化字段,必须加入恢复与保存逻辑,并更新本节。
---

View File

@@ -14,7 +14,7 @@ ViGent2 的前端界面,采用 Next.js 16 + TailwindCSS 构建。
- **预览一致性**: 标题/字幕预览按素材分辨率缩放,效果更接近成片 (Day 17)。
- **进度追踪**: 实时显示视频生成进度 (10% -> 100%)。
- **作品预览**: 生成完成后直接播放下载(作品预览 + 历史作品)。
- **本地保存**: 文案/标题自动保存,刷新后恢复 (Day 14)。
- **本地保存**: 文案/标题/偏好由 `useHomePersistence` 统一持久化,刷新后恢复 (Day 14/17)。
### 2. 全自动发布 (`/publish`) [Day 7 新增]
- **多平台管理**: 统一管理 B站、抖音、小红书账号状态。
@@ -24,6 +24,7 @@ ViGent2 的前端界面,采用 Next.js 16 + TailwindCSS 构建。
- Cookie 自动保存与状态同步。
- **发布配置**: 设置视频标题、标签、简介。
- **作品选择**: 卡片列表 + 搜索 + 预览弹窗。
- **预览兼容**: 签名 URL / 相对路径均可直接预览。
- **定时任务**: 支持 "立即发布" 或 "定时发布"。
### 3. 声音克隆 [Day 13 新增]

View File

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

View File

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

View File

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

View File

@@ -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()) {

View File

@@ -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="预览"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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