1258 lines
38 KiB
TypeScript
1258 lines
38 KiB
TypeScript
import { useEffect, useMemo, useRef, useState } from "react";
|
||
import api from "@/shared/api/axios";
|
||
import {
|
||
buildTextShadow,
|
||
formatDate,
|
||
getApiBaseUrl,
|
||
getFontFormat,
|
||
resolveAssetUrl,
|
||
resolveBgmUrl,
|
||
resolveMediaUrl,
|
||
} from "@/shared/lib/media";
|
||
import { clampTitle, clampSecondaryTitle, SECONDARY_TITLE_MAX_LENGTH } from "@/shared/lib/title";
|
||
import { useTitleInput } from "@/shared/hooks/useTitleInput";
|
||
import { useAuth } from "@/shared/contexts/AuthContext";
|
||
import { useTask } from "@/shared/contexts/TaskContext";
|
||
import { toast } from "sonner";
|
||
import { usePublishPrefetch } from "@/shared/hooks/usePublishPrefetch";
|
||
import { PublishAccount } from "@/shared/types/publish";
|
||
import { useBgm } from "@/features/home/model/useBgm";
|
||
import { useGeneratedVideos } from "@/features/home/model/useGeneratedVideos";
|
||
import { useGeneratedAudios } from "@/features/home/model/useGeneratedAudios";
|
||
import { useHomePersistence } from "@/features/home/model/useHomePersistence";
|
||
import { useMaterials } from "@/features/home/model/useMaterials";
|
||
import { useMediaPlayers } from "@/features/home/model/useMediaPlayers";
|
||
import { useRefAudios } from "@/features/home/model/useRefAudios";
|
||
import { useTitleSubtitleStyles } from "@/features/home/model/useTitleSubtitleStyles";
|
||
import { useTimelineEditor } from "@/features/home/model/useTimelineEditor";
|
||
import { useSavedScripts } from "@/features/home/model/useSavedScripts";
|
||
import { useVideoFrameCapture } from "@/features/home/model/useVideoFrameCapture";
|
||
import { ApiResponse, unwrap } from "@/shared/api/types";
|
||
|
||
const VOICES: Record<string, { id: string; name: string }[]> = {
|
||
"zh-CN": [
|
||
{ id: "zh-CN-YunxiNeural", name: "云溪 (男声-年轻)" },
|
||
{ id: "zh-CN-YunjianNeural", name: "云健 (男声-新闻)" },
|
||
{ id: "zh-CN-YunyangNeural", name: "云扬 (男声-专业)" },
|
||
{ id: "zh-CN-XiaoxiaoNeural", name: "晓晓 (女声-活泼)" },
|
||
{ id: "zh-CN-XiaoyiNeural", name: "晓伊 (女声-温柔)" },
|
||
],
|
||
"en-US": [
|
||
{ id: "en-US-GuyNeural", name: "Guy (Male)" },
|
||
{ id: "en-US-JennyNeural", name: "Jenny (Female)" },
|
||
],
|
||
"ja-JP": [
|
||
{ id: "ja-JP-KeitaNeural", name: "圭太 (男声)" },
|
||
{ id: "ja-JP-NanamiNeural", name: "七海 (女声)" },
|
||
],
|
||
"ko-KR": [
|
||
{ id: "ko-KR-InJoonNeural", name: "인준 (男声)" },
|
||
{ id: "ko-KR-SunHiNeural", name: "선히 (女声)" },
|
||
],
|
||
"fr-FR": [
|
||
{ id: "fr-FR-HenriNeural", name: "Henri (Male)" },
|
||
{ id: "fr-FR-DeniseNeural", name: "Denise (Female)" },
|
||
],
|
||
"de-DE": [
|
||
{ id: "de-DE-ConradNeural", name: "Conrad (Male)" },
|
||
{ id: "de-DE-KatjaNeural", name: "Katja (Female)" },
|
||
],
|
||
"es-ES": [
|
||
{ id: "es-ES-AlvaroNeural", name: "Álvaro (Male)" },
|
||
{ id: "es-ES-ElviraNeural", name: "Elvira (Female)" },
|
||
],
|
||
"ru-RU": [
|
||
{ id: "ru-RU-DmitryNeural", name: "Дмитрий (Male)" },
|
||
{ id: "ru-RU-SvetlanaNeural", name: "Светлана (Female)" },
|
||
],
|
||
"it-IT": [
|
||
{ id: "it-IT-DiegoNeural", name: "Diego (Male)" },
|
||
{ id: "it-IT-ElsaNeural", name: "Elsa (Female)" },
|
||
],
|
||
"pt-BR": [
|
||
{ id: "pt-BR-AntonioNeural", name: "Antonio (Male)" },
|
||
{ id: "pt-BR-FranciscaNeural", name: "Francisca (Female)" },
|
||
],
|
||
};
|
||
|
||
const LANG_TO_LOCALE: Record<string, string> = {
|
||
"中文": "zh-CN",
|
||
"English": "en-US",
|
||
"日本語": "ja-JP",
|
||
"한국어": "ko-KR",
|
||
"Français": "fr-FR",
|
||
"Deutsch": "de-DE",
|
||
"Español": "es-ES",
|
||
"Русский": "ru-RU",
|
||
"Italiano": "it-IT",
|
||
"Português": "pt-BR",
|
||
};
|
||
|
||
const DEFAULT_SHORT_TITLE_DURATION = 4;
|
||
|
||
|
||
|
||
const scrollContainerToItem = (container: HTMLDivElement, item: HTMLDivElement) => {
|
||
const containerRect = container.getBoundingClientRect();
|
||
const itemRect = item.getBoundingClientRect();
|
||
const itemTop = itemRect.top - containerRect.top + container.scrollTop;
|
||
const itemBottom = itemTop + itemRect.height;
|
||
const viewTop = container.scrollTop;
|
||
const viewBottom = viewTop + container.clientHeight;
|
||
|
||
if (itemTop < viewTop) {
|
||
container.scrollTo({ top: Math.max(itemTop - 8, 0), behavior: "smooth" });
|
||
} else if (itemBottom > viewBottom) {
|
||
container.scrollTo({ top: itemBottom - container.clientHeight + 8, behavior: "smooth" });
|
||
}
|
||
};
|
||
|
||
interface GeneratedVideo {
|
||
id: string;
|
||
name: string;
|
||
path: string;
|
||
size_mb: number;
|
||
created_at: number;
|
||
}
|
||
|
||
interface RefAudio {
|
||
id: string;
|
||
name: string;
|
||
path: string;
|
||
ref_text: string;
|
||
duration_sec: number;
|
||
created_at: number;
|
||
}
|
||
|
||
type LipsyncModelMode = "default" | "fast" | "advanced";
|
||
|
||
import type { Material } from "@/shared/types/material";
|
||
|
||
export const useHomeController = () => {
|
||
const apiBase = getApiBaseUrl();
|
||
|
||
const [selectedMaterials, setSelectedMaterials] = useState<string[]>([]);
|
||
const [previewMaterial, setPreviewMaterial] = useState<string | null>(null);
|
||
|
||
const [text, setText] = useState<string>("");
|
||
const [voice, setVoice] = useState<string>("zh-CN-YunxiNeural");
|
||
const [textLang, setTextLang] = useState<string>("zh-CN");
|
||
|
||
// 使用全局任务状态
|
||
const { currentTask, isGenerating, startTask } = useTask();
|
||
const prevIsGenerating = useRef(isGenerating);
|
||
|
||
const [generatedVideo, setGeneratedVideo] = useState<string | null>(null);
|
||
const [selectedVideoId, setSelectedVideoId] = useState<string | null>(null);
|
||
|
||
// 字幕和标题相关状态
|
||
const [videoTitle, setVideoTitle] = useState<string>("");
|
||
const [selectedSubtitleStyleId, setSelectedSubtitleStyleId] = useState<string>("");
|
||
const [selectedTitleStyleId, setSelectedTitleStyleId] = useState<string>("");
|
||
const [subtitleFontSize, setSubtitleFontSize] = useState<number>(80);
|
||
const [titleFontSize, setTitleFontSize] = useState<number>(120);
|
||
const [subtitleSizeLocked, setSubtitleSizeLocked] = useState<boolean>(false);
|
||
const [titleSizeLocked, setTitleSizeLocked] = useState<boolean>(false);
|
||
const [titleTopMargin, setTitleTopMargin] = useState<number>(62);
|
||
const [titleDisplayMode, setTitleDisplayMode] = useState<"short" | "persistent">("short");
|
||
const [subtitleBottomMargin, setSubtitleBottomMargin] = useState<number>(80);
|
||
const [outputAspectRatio, setOutputAspectRatio] = useState<"9:16" | "16:9">("9:16");
|
||
const [lipsyncModelMode, setLipsyncModelMode] = useState<LipsyncModelMode>("default");
|
||
const [showStylePreview, setShowStylePreview] = useState<boolean>(false);
|
||
const [materialDimensions, setMaterialDimensions] = useState<{ width: number; height: number } | null>(null);
|
||
|
||
// 副标题相关状态
|
||
const [videoSecondaryTitle, setVideoSecondaryTitle] = useState<string>("");
|
||
const [selectedSecondaryTitleStyleId, setSelectedSecondaryTitleStyleId] = useState<string>("");
|
||
const [secondaryTitleFontSize, setSecondaryTitleFontSize] = useState<number>(48);
|
||
const [secondaryTitleTopMargin, setSecondaryTitleTopMargin] = useState<number>(12);
|
||
const [secondaryTitleSizeLocked, setSecondaryTitleSizeLocked] = useState<boolean>(false);
|
||
|
||
|
||
// 背景音乐相关状态
|
||
const [selectedBgmId, setSelectedBgmId] = useState<string>("");
|
||
const [enableBgm, setEnableBgm] = useState<boolean>(false);
|
||
const [bgmVolume, setBgmVolume] = useState<number>(0.2);
|
||
|
||
// 声音克隆相关状态
|
||
const [ttsMode, setTtsMode] = useState<"edgetts" | "voiceclone">("edgetts");
|
||
const [selectedRefAudio, setSelectedRefAudio] = useState<RefAudio | null>(null);
|
||
const [refText, setRefText] = useState("");
|
||
|
||
// 预生成配音选中 ID
|
||
const [selectedAudioId, setSelectedAudioId] = useState<string | null>(null);
|
||
|
||
// 语速控制
|
||
const [speed, setSpeed] = useState<number>(1.0);
|
||
|
||
// 语气控制(仅声音克隆模式)
|
||
const [emotion, setEmotion] = useState<string>("normal");
|
||
|
||
// ClipTrimmer 模态框状态
|
||
const [clipTrimmerOpen, setClipTrimmerOpen] = useState(false);
|
||
const [clipTrimmerSegmentId, setClipTrimmerSegmentId] = useState<string | null>(null);
|
||
|
||
// 音频预览与重命名状态
|
||
const [editingAudioId, setEditingAudioId] = useState<string | null>(null);
|
||
const [editName, setEditName] = useState("");
|
||
const [editingMaterialId, setEditingMaterialId] = useState<string | null>(null);
|
||
const [editMaterialName, setEditMaterialName] = useState("");
|
||
const bgmItemRefs = useRef<Record<string, HTMLDivElement | null>>({});
|
||
const bgmListContainerRef = useRef<HTMLDivElement | null>(null);
|
||
|
||
const materialItemRefs = useRef<Record<string, HTMLDivElement | null>>({});
|
||
const videoItemRefs = useRef<Record<string, HTMLDivElement | null>>({});
|
||
|
||
// 重命名参考音频
|
||
const startEditing = (audio: RefAudio, e: React.MouseEvent) => {
|
||
e.stopPropagation();
|
||
setEditingAudioId(audio.id);
|
||
// 去掉后缀名进行编辑 (体验更好)
|
||
const nameWithoutExt = audio.name.substring(0, audio.name.lastIndexOf("."));
|
||
setEditName(nameWithoutExt || audio.name);
|
||
};
|
||
|
||
const cancelEditing = (e: React.MouseEvent) => {
|
||
e.stopPropagation();
|
||
setEditingAudioId(null);
|
||
setEditName("");
|
||
};
|
||
|
||
const saveEditing = async (audioId: string, e: React.MouseEvent) => {
|
||
e.stopPropagation();
|
||
if (!editName.trim()) return;
|
||
|
||
try {
|
||
await api.put(`/api/ref-audios/${encodeURIComponent(audioId)}`, { new_name: editName });
|
||
setEditingAudioId(null);
|
||
fetchRefAudios(); // 刷新列表
|
||
} catch (err: unknown) {
|
||
toast.error("重命名失败: " + String(err));
|
||
}
|
||
};
|
||
|
||
const startMaterialEditing = (material: Material, e: React.MouseEvent) => {
|
||
e.stopPropagation();
|
||
setEditingMaterialId(material.id);
|
||
const nameWithoutExt = material.name.substring(0, material.name.lastIndexOf("."));
|
||
setEditMaterialName(nameWithoutExt || material.name);
|
||
};
|
||
|
||
const cancelMaterialEditing = (e: React.MouseEvent) => {
|
||
e.stopPropagation();
|
||
setEditingMaterialId(null);
|
||
setEditMaterialName("");
|
||
};
|
||
|
||
const saveMaterialEditing = async (materialId: string, e: React.MouseEvent) => {
|
||
e.stopPropagation();
|
||
if (!editMaterialName.trim()) return;
|
||
|
||
try {
|
||
const { data: res } = await api.put<ApiResponse<{ id: string }>>(
|
||
`/api/materials/${encodeURIComponent(materialId)}`,
|
||
{ new_name: editMaterialName.trim() }
|
||
);
|
||
const payload = unwrap(res);
|
||
if (selectedMaterials.includes(materialId) && payload?.id) {
|
||
setSelectedMaterials((prev) => prev.map((x) => (x === materialId ? payload.id : x)));
|
||
}
|
||
setEditingMaterialId(null);
|
||
setEditMaterialName("");
|
||
fetchMaterials();
|
||
} catch (err: unknown) {
|
||
const axiosErr = err as { response?: { data?: { message?: string } }; message?: string };
|
||
const errorMsg = axiosErr.response?.data?.message || axiosErr.message || String(err);
|
||
toast.error(`重命名失败: ${errorMsg}`);
|
||
}
|
||
};
|
||
|
||
// AI 生成标题标签
|
||
const [isGeneratingMeta, setIsGeneratingMeta] = useState(false);
|
||
|
||
// AI 多语言翻译
|
||
const [isTranslating, setIsTranslating] = useState(false);
|
||
const [originalText, setOriginalText] = useState<string | null>(null);
|
||
|
||
// 在线录音相关
|
||
const [isRecording, setIsRecording] = useState(false);
|
||
const [recordedBlob, setRecordedBlob] = useState<Blob | null>(null);
|
||
const [recordingTime, setRecordingTime] = useState(0);
|
||
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
||
const recordingIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||
|
||
// 使用全局认证状态
|
||
const { userId, isLoading: isAuthLoading } = useAuth();
|
||
|
||
// 文案提取模态框
|
||
const [extractModalOpen, setExtractModalOpen] = useState(false);
|
||
|
||
// AI 改写模态框
|
||
const [rewriteModalOpen, setRewriteModalOpen] = useState(false);
|
||
|
||
// 获取存储 key 的前缀(登录用户使用 userId,未登录使用 guest)
|
||
const storageKey = userId || "guest";
|
||
|
||
// 使用共用的发布预加载 hook
|
||
const { updatePrefetch: updatePublishPrefetch } = usePublishPrefetch();
|
||
|
||
const {
|
||
materials,
|
||
fetchError,
|
||
isFetching,
|
||
lastMaterialCount,
|
||
isUploading,
|
||
uploadProgress,
|
||
uploadError,
|
||
setUploadError,
|
||
fetchMaterials,
|
||
toggleMaterial,
|
||
deleteMaterial,
|
||
handleUpload,
|
||
} = useMaterials({
|
||
selectedMaterials,
|
||
setSelectedMaterials,
|
||
});
|
||
|
||
const {
|
||
subtitleStyles,
|
||
titleStyles,
|
||
refreshSubtitleStyles,
|
||
refreshTitleStyles,
|
||
} = useTitleSubtitleStyles({
|
||
isAuthLoading,
|
||
|
||
setSelectedSubtitleStyleId,
|
||
setSelectedTitleStyleId,
|
||
});
|
||
|
||
const {
|
||
refAudios,
|
||
isUploadingRef,
|
||
uploadRefError,
|
||
setUploadRefError,
|
||
fetchRefAudios,
|
||
uploadRefAudio,
|
||
deleteRefAudio,
|
||
retranscribeRefAudio,
|
||
retranscribingId,
|
||
} = useRefAudios({
|
||
selectedRefAudio,
|
||
setSelectedRefAudio,
|
||
setRefText,
|
||
});
|
||
|
||
const {
|
||
bgmList,
|
||
bgmLoading,
|
||
bgmError,
|
||
fetchBgmList,
|
||
} = useBgm({
|
||
|
||
selectedBgmId,
|
||
setSelectedBgmId,
|
||
});
|
||
|
||
const {
|
||
playingAudioId,
|
||
playingBgmId,
|
||
togglePlayPreview,
|
||
toggleBgmPreview,
|
||
} = useMediaPlayers({
|
||
bgmVolume,
|
||
resolveBgmUrl,
|
||
resolveMediaUrl,
|
||
setSelectedBgmId,
|
||
setEnableBgm,
|
||
});
|
||
|
||
const {
|
||
generatedVideos,
|
||
fetchGeneratedVideos,
|
||
deleteVideo,
|
||
} = useGeneratedVideos({
|
||
storageKey,
|
||
selectedVideoId,
|
||
setSelectedVideoId,
|
||
setGeneratedVideo,
|
||
resolveMediaUrl,
|
||
});
|
||
|
||
const {
|
||
generatedAudios,
|
||
selectedAudio,
|
||
isGeneratingAudio,
|
||
audioTask,
|
||
fetchGeneratedAudios,
|
||
generateAudio,
|
||
deleteAudio,
|
||
renameAudio,
|
||
selectAudio,
|
||
} = useGeneratedAudios({
|
||
selectedAudioId,
|
||
setSelectedAudioId,
|
||
});
|
||
|
||
const {
|
||
segments: timelineSegments,
|
||
reorderSegments,
|
||
setSourceRange,
|
||
toCustomAssignments,
|
||
} = useTimelineEditor({
|
||
audioDuration: selectedAudio?.duration_sec ?? 0,
|
||
materials,
|
||
selectedMaterials,
|
||
storageKey,
|
||
});
|
||
|
||
// 时间轴第一段素材的视频 URL(用于帧截取预览)
|
||
// 使用后端代理 URL(同源)避免 CORS canvas taint
|
||
const firstTimelineMaterialUrl = useMemo(() => {
|
||
const firstSeg = timelineSegments[0];
|
||
const matId = firstSeg?.materialId ?? selectedMaterials[0];
|
||
if (!matId) return null;
|
||
const mat = materials.find((m) => m.id === matId);
|
||
if (!mat) return null;
|
||
return `/api/materials/stream/${mat.id}`;
|
||
}, [materials, timelineSegments, selectedMaterials]);
|
||
|
||
const materialPosterUrl = useVideoFrameCapture(showStylePreview ? firstTimelineMaterialUrl : null);
|
||
|
||
useEffect(() => {
|
||
if (isAuthLoading || !userId) return;
|
||
let active = true;
|
||
|
||
const prefetchAccounts = async () => {
|
||
try {
|
||
const { data: res } = await api.get<ApiResponse<{ accounts: PublishAccount[] }>>(
|
||
"/api/publish/accounts"
|
||
);
|
||
if (!active) return;
|
||
const payload = unwrap(res);
|
||
updatePublishPrefetch({ accounts: payload.accounts || [] });
|
||
} catch (error) {
|
||
console.error("预取账号失败:", error);
|
||
}
|
||
};
|
||
|
||
void prefetchAccounts();
|
||
return () => {
|
||
active = false;
|
||
};
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [isAuthLoading, userId]);
|
||
|
||
useEffect(() => {
|
||
if (generatedVideos.length === 0) return;
|
||
const prefetched = generatedVideos.map((video) => ({
|
||
id: video.id,
|
||
name: formatDate(video.created_at) + ` (${video.size_mb.toFixed(1)}MB)`,
|
||
path: video.path.startsWith("/") ? video.path.slice(1) : video.path,
|
||
}));
|
||
updatePublishPrefetch({ videos: prefetched });
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [generatedVideos]);
|
||
|
||
const { isRestored } = useHomePersistence({
|
||
isAuthLoading,
|
||
storageKey,
|
||
text,
|
||
setText,
|
||
videoTitle,
|
||
setVideoTitle,
|
||
videoSecondaryTitle,
|
||
setVideoSecondaryTitle,
|
||
ttsMode,
|
||
setTtsMode,
|
||
voice,
|
||
setVoice,
|
||
textLang,
|
||
setTextLang,
|
||
selectedMaterials,
|
||
setSelectedMaterials,
|
||
selectedSubtitleStyleId,
|
||
setSelectedSubtitleStyleId,
|
||
selectedTitleStyleId,
|
||
setSelectedTitleStyleId,
|
||
selectedSecondaryTitleStyleId,
|
||
setSelectedSecondaryTitleStyleId,
|
||
subtitleFontSize,
|
||
setSubtitleFontSize,
|
||
titleFontSize,
|
||
setTitleFontSize,
|
||
secondaryTitleFontSize,
|
||
setSecondaryTitleFontSize,
|
||
setSubtitleSizeLocked,
|
||
setTitleSizeLocked,
|
||
setSecondaryTitleSizeLocked,
|
||
titleTopMargin,
|
||
setTitleTopMargin,
|
||
secondaryTitleTopMargin,
|
||
setSecondaryTitleTopMargin,
|
||
titleDisplayMode,
|
||
setTitleDisplayMode,
|
||
subtitleBottomMargin,
|
||
setSubtitleBottomMargin,
|
||
outputAspectRatio,
|
||
setOutputAspectRatio,
|
||
lipsyncModelMode,
|
||
setLipsyncModelMode,
|
||
selectedBgmId,
|
||
setSelectedBgmId,
|
||
bgmVolume,
|
||
setBgmVolume,
|
||
enableBgm,
|
||
setEnableBgm,
|
||
selectedVideoId,
|
||
setSelectedVideoId,
|
||
selectedRefAudio,
|
||
selectedAudioId,
|
||
setSelectedAudioId,
|
||
speed,
|
||
setSpeed,
|
||
emotion,
|
||
setEmotion,
|
||
});
|
||
|
||
const { savedScripts, saveScript, deleteScript: deleteSavedScript } = useSavedScripts(storageKey);
|
||
|
||
const handleSaveScript = () => {
|
||
if (!text.trim()) return;
|
||
saveScript(text);
|
||
toast.success("文案已保存");
|
||
};
|
||
|
||
const syncTitleToPublish = (value: string) => {
|
||
if (typeof window !== "undefined") {
|
||
localStorage.setItem(`vigent_${storageKey}_publish_title`, value);
|
||
}
|
||
};
|
||
|
||
const titleInput = useTitleInput({
|
||
value: videoTitle,
|
||
onChange: setVideoTitle,
|
||
onCommit: syncTitleToPublish,
|
||
});
|
||
|
||
const secondaryTitleInput = useTitleInput({
|
||
value: videoSecondaryTitle,
|
||
onChange: setVideoSecondaryTitle,
|
||
maxLength: SECONDARY_TITLE_MAX_LENGTH,
|
||
});
|
||
|
||
// 加载素材列表和历史视频
|
||
useEffect(() => {
|
||
if (isAuthLoading) return;
|
||
void Promise.allSettled([
|
||
fetchMaterials(),
|
||
fetchGeneratedVideos(),
|
||
fetchRefAudios(),
|
||
fetchGeneratedAudios(),
|
||
refreshSubtitleStyles(),
|
||
refreshTitleStyles(),
|
||
fetchBgmList(),
|
||
]);
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [isAuthLoading]);
|
||
|
||
// 监听任务完成,自动刷新视频列表并选中最新
|
||
useEffect(() => {
|
||
if (prevIsGenerating.current && !isGenerating) {
|
||
if (currentTask?.status === "completed") {
|
||
void fetchGeneratedVideos("__latest__");
|
||
} else {
|
||
void fetchGeneratedVideos();
|
||
}
|
||
}
|
||
prevIsGenerating.current = isGenerating;
|
||
}, [isGenerating, currentTask, fetchGeneratedVideos]);
|
||
|
||
useEffect(() => {
|
||
const firstSelected = selectedMaterials[0];
|
||
const material = materials.find((item) => item.id === firstSelected);
|
||
if (!material?.path) {
|
||
setMaterialDimensions(null);
|
||
return;
|
||
}
|
||
const url = resolveMediaUrl(material.path);
|
||
if (!url) {
|
||
setMaterialDimensions(null);
|
||
return;
|
||
}
|
||
|
||
let isActive = true;
|
||
const video = document.createElement("video");
|
||
video.preload = "metadata";
|
||
video.src = url;
|
||
video.load();
|
||
|
||
const handleLoaded = () => {
|
||
if (!isActive) return;
|
||
if (video.videoWidth && video.videoHeight) {
|
||
setMaterialDimensions({ width: video.videoWidth, height: video.videoHeight });
|
||
} else {
|
||
setMaterialDimensions(null);
|
||
}
|
||
};
|
||
|
||
const handleError = () => {
|
||
if (!isActive) return;
|
||
setMaterialDimensions(null);
|
||
};
|
||
|
||
video.addEventListener("loadedmetadata", handleLoaded);
|
||
video.addEventListener("error", handleError);
|
||
|
||
return () => {
|
||
isActive = false;
|
||
video.removeEventListener("loadedmetadata", handleLoaded);
|
||
video.removeEventListener("error", handleError);
|
||
};
|
||
}, [materials, selectedMaterials]);
|
||
|
||
|
||
useEffect(() => {
|
||
if (subtitleSizeLocked || subtitleStyles.length === 0) return;
|
||
const active = subtitleStyles.find((s) => s.id === selectedSubtitleStyleId)
|
||
|| subtitleStyles.find((s) => s.is_default)
|
||
|| subtitleStyles[0];
|
||
if (active?.font_size) {
|
||
setSubtitleFontSize(active.font_size);
|
||
}
|
||
}, [subtitleStyles, selectedSubtitleStyleId, subtitleSizeLocked]);
|
||
|
||
useEffect(() => {
|
||
if (titleSizeLocked || titleStyles.length === 0) return;
|
||
const active = titleStyles.find((s) => s.id === selectedTitleStyleId)
|
||
|| titleStyles.find((s) => s.is_default)
|
||
|| titleStyles[0];
|
||
if (active?.font_size) {
|
||
setTitleFontSize(active.font_size);
|
||
}
|
||
}, [titleStyles, selectedTitleStyleId, titleSizeLocked]);
|
||
|
||
useEffect(() => {
|
||
if (secondaryTitleSizeLocked || titleStyles.length === 0) return;
|
||
const active = titleStyles.find((s) => s.id === selectedSecondaryTitleStyleId)
|
||
|| titleStyles.find((s) => s.is_default)
|
||
|| titleStyles[0];
|
||
if (active?.font_size) {
|
||
setSecondaryTitleFontSize(active.font_size);
|
||
}
|
||
}, [titleStyles, selectedSecondaryTitleStyleId, secondaryTitleSizeLocked]);
|
||
|
||
// 移除重复的 BGM 持久化恢复逻辑 (已统一移动到 useHomePersistence 中)
|
||
// useEffect(() => { ... })
|
||
|
||
// 时间门控:页面加载后 1 秒内禁止所有列表自动滚动效果
|
||
// 防止持久化恢复 + 异步数据加载触发 scrollIntoView 导致移动端页面跳动
|
||
const scrollEffectsEnabled = useRef(false);
|
||
useEffect(() => {
|
||
const timer = setTimeout(() => {
|
||
scrollEffectsEnabled.current = true;
|
||
}, 1000);
|
||
return () => clearTimeout(timer);
|
||
}, []);
|
||
|
||
// BGM 列表滚动
|
||
useEffect(() => {
|
||
if (!selectedBgmId || !scrollEffectsEnabled.current) return;
|
||
const container = bgmListContainerRef.current;
|
||
const target = bgmItemRefs.current[selectedBgmId];
|
||
if (container && target) {
|
||
scrollContainerToItem(container, target);
|
||
}
|
||
}, [selectedBgmId, bgmList]);
|
||
|
||
// 素材列表滚动
|
||
useEffect(() => {
|
||
const firstSelected = selectedMaterials[0];
|
||
if (!firstSelected || !scrollEffectsEnabled.current) return;
|
||
const target = materialItemRefs.current[firstSelected];
|
||
if (target) {
|
||
target.scrollIntoView({ block: "nearest", behavior: "smooth" });
|
||
}
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [selectedMaterials.length]);
|
||
|
||
// 【修复】历史视频默认选中逻辑
|
||
// 当持久化恢复完成,且列表加载完毕,如果没选中任何视频,默认选中第一个
|
||
useEffect(() => {
|
||
if (isRestored && generatedVideos.length > 0 && !selectedVideoId) {
|
||
const firstId = generatedVideos[0].id;
|
||
setSelectedVideoId(firstId);
|
||
setGeneratedVideo(resolveMediaUrl(generatedVideos[0].path));
|
||
}
|
||
}, [isRestored, generatedVideos, selectedVideoId, setSelectedVideoId, setGeneratedVideo]);
|
||
|
||
// 【修复】BGM 默认选中逻辑
|
||
useEffect(() => {
|
||
if (isRestored && bgmList.length > 0 && !selectedBgmId && enableBgm) {
|
||
setSelectedBgmId(bgmList[0].id);
|
||
}
|
||
}, [isRestored, bgmList, selectedBgmId, enableBgm, setSelectedBgmId]);
|
||
|
||
// 视频列表滚动
|
||
useEffect(() => {
|
||
if (!selectedVideoId || !scrollEffectsEnabled.current) return;
|
||
const target = videoItemRefs.current[selectedVideoId];
|
||
if (target) {
|
||
target.scrollIntoView({ block: "nearest", behavior: "smooth" });
|
||
}
|
||
}, [selectedVideoId, generatedVideos]);
|
||
|
||
// 自动选择参考音频 (恢复上次选择 或 默认最新的)
|
||
useEffect(() => {
|
||
// 只有在数据加载完成且尚未选择时才执行
|
||
if (refAudios.length > 0 && !selectedRefAudio && isRestored) {
|
||
const savedId = localStorage.getItem(`vigent_${storageKey}_refAudioId`);
|
||
let targetAudio = null;
|
||
|
||
if (savedId) {
|
||
targetAudio = refAudios.find((a) => a.id === savedId);
|
||
}
|
||
|
||
// 如果没找到保存的,或者没有保存,则默认选第一个(最新的)
|
||
if (!targetAudio) {
|
||
targetAudio = refAudios[0];
|
||
}
|
||
|
||
setSelectedRefAudio(targetAudio);
|
||
setRefText(targetAudio.ref_text);
|
||
}
|
||
}, [refAudios, selectedRefAudio, isRestored, storageKey, setSelectedRefAudio, setRefText]);
|
||
|
||
useEffect(() => {
|
||
if (!selectedRefAudio || !isRestored) return;
|
||
localStorage.setItem(`vigent_${storageKey}_refAudioId`, selectedRefAudio.id);
|
||
}, [selectedRefAudio, storageKey, isRestored]);
|
||
|
||
useEffect(() => {
|
||
if (!selectedRefAudio) return;
|
||
setRefText(selectedRefAudio.ref_text);
|
||
}, [selectedRefAudio]);
|
||
|
||
// 开始录音
|
||
const startRecording = async () => {
|
||
try {
|
||
setRecordedBlob(null);
|
||
setRecordingTime(0);
|
||
|
||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||
const mediaRecorder = new MediaRecorder(stream, { mimeType: "audio/webm" });
|
||
const chunks: BlobPart[] = [];
|
||
|
||
mediaRecorder.ondataavailable = (e) => chunks.push(e.data);
|
||
mediaRecorder.onstop = () => {
|
||
const blob = new Blob(chunks, { type: "audio/webm" });
|
||
setRecordedBlob(blob);
|
||
stream.getTracks().forEach((track) => track.stop());
|
||
};
|
||
|
||
mediaRecorder.start();
|
||
setIsRecording(true);
|
||
mediaRecorderRef.current = mediaRecorder;
|
||
|
||
// 计时器
|
||
recordingIntervalRef.current = setInterval(() => {
|
||
setRecordingTime((prev) => prev + 1);
|
||
}, 1000);
|
||
} catch (err) {
|
||
toast.error("无法访问麦克风,请检查权限设置");
|
||
console.error(err);
|
||
}
|
||
};
|
||
|
||
// 停止录音
|
||
const stopRecording = () => {
|
||
mediaRecorderRef.current?.stop();
|
||
setIsRecording(false);
|
||
if (recordingIntervalRef.current) {
|
||
clearInterval(recordingIntervalRef.current);
|
||
recordingIntervalRef.current = null;
|
||
}
|
||
};
|
||
|
||
// 使用录音(上传到后端,使用固定参考文字)
|
||
const useRecording = async () => {
|
||
if (!recordedBlob) return;
|
||
|
||
// 回归:使用固定文件名,依靠后端自动重命名 (recording(1).webm)
|
||
const filename = "recording.webm";
|
||
|
||
const file = new File([recordedBlob], filename, { type: "audio/webm" });
|
||
await uploadRefAudio(file);
|
||
setRecordedBlob(null);
|
||
setRecordingTime(0);
|
||
};
|
||
|
||
const discardRecording = () => {
|
||
setRecordedBlob(null);
|
||
setRecordingTime(0);
|
||
};
|
||
|
||
// 格式化录音时长
|
||
const formatRecordingTime = (seconds: number) => {
|
||
const mins = Math.floor(seconds / 60);
|
||
const secs = seconds % 60;
|
||
return `${mins}:${secs.toString().padStart(2, "0")}`;
|
||
};
|
||
|
||
// AI 生成标题和标签
|
||
const handleGenerateMeta = async () => {
|
||
if (!text.trim()) {
|
||
toast.error("请先输入口播文案");
|
||
return;
|
||
}
|
||
|
||
setIsGeneratingMeta(true);
|
||
try {
|
||
const { data: res } = await api.post<ApiResponse<{ title?: string; secondary_title?: string; tags?: string[] }>>(
|
||
"/api/ai/generate-meta",
|
||
{ text: text.trim() }
|
||
);
|
||
const payload = unwrap(res);
|
||
|
||
// 更新首页标题
|
||
const nextTitle = clampTitle(payload.title || "");
|
||
titleInput.commitValue(nextTitle);
|
||
|
||
// 更新副标题
|
||
const nextSecondaryTitle = clampSecondaryTitle(payload.secondary_title || "");
|
||
secondaryTitleInput.commitValue(nextSecondaryTitle);
|
||
|
||
// 同步到发布页 localStorage
|
||
localStorage.setItem(`vigent_${storageKey}_publish_tags`, JSON.stringify(payload.tags || []));
|
||
} catch (err: unknown) {
|
||
console.error("AI generate meta failed:", err);
|
||
const axiosErr = err as { response?: { data?: { message?: string } }; message?: string };
|
||
const errorMsg = axiosErr.response?.data?.message || axiosErr.message || String(err);
|
||
toast.error(`AI 生成失败: ${errorMsg}`);
|
||
} finally {
|
||
setIsGeneratingMeta(false);
|
||
}
|
||
};
|
||
|
||
// AI 多语言翻译
|
||
const handleTranslate = async (targetLang: string) => {
|
||
if (!text.trim()) {
|
||
toast.error("请先输入口播文案");
|
||
return;
|
||
}
|
||
|
||
// 首次翻译时保存原文
|
||
if (originalText === null) {
|
||
setOriginalText(text);
|
||
}
|
||
|
||
setIsTranslating(true);
|
||
try {
|
||
const { data: res } = await api.post<ApiResponse<{ translated_text: string }>>(
|
||
"/api/ai/translate",
|
||
{ text: text.trim(), target_lang: targetLang }
|
||
);
|
||
const payload = unwrap(res);
|
||
setText(payload.translated_text || "");
|
||
|
||
// 根据翻译目标语言更新 textLang 并自动切换声音
|
||
const locale = LANG_TO_LOCALE[targetLang] || "zh-CN";
|
||
setTextLang(locale);
|
||
if (ttsMode === "edgetts") {
|
||
const langVoices = VOICES[locale] || VOICES["zh-CN"];
|
||
setVoice(langVoices[0].id);
|
||
}
|
||
} catch (err: unknown) {
|
||
console.error("AI translate failed:", err);
|
||
const axiosErr = err as { response?: { data?: { message?: string } }; message?: string };
|
||
const errorMsg = axiosErr.response?.data?.message || axiosErr.message || String(err);
|
||
toast.error(`AI 翻译失败: ${errorMsg}`);
|
||
} finally {
|
||
setIsTranslating(false);
|
||
}
|
||
};
|
||
|
||
const handleRestoreOriginal = () => {
|
||
if (originalText !== null) {
|
||
setText(originalText);
|
||
setOriginalText(null);
|
||
setTextLang("zh-CN");
|
||
if (ttsMode === "edgetts") {
|
||
setVoice(VOICES["zh-CN"][0].id);
|
||
}
|
||
}
|
||
};
|
||
|
||
// 生成配音
|
||
const handleGenerateAudio = async () => {
|
||
if (!text.trim()) {
|
||
toast.error("请先输入文案");
|
||
return;
|
||
}
|
||
if (ttsMode === "voiceclone" && !selectedRefAudio) {
|
||
toast.error("请选择参考音频");
|
||
return;
|
||
}
|
||
|
||
const emotionToInstruct: Record<string, string> = {
|
||
normal: "",
|
||
happy: "You are a helpful assistant. 请非常开心地说一句话。<|endofprompt|>",
|
||
sad: "You are a helpful assistant. 请非常伤心地说一句话。<|endofprompt|>",
|
||
angry: "You are a helpful assistant. 请非常生气地说一句话。<|endofprompt|>",
|
||
};
|
||
|
||
const params = {
|
||
text: text.trim(),
|
||
tts_mode: ttsMode,
|
||
voice: ttsMode === "edgetts" ? voice : undefined,
|
||
ref_audio_id: ttsMode === "voiceclone" ? selectedRefAudio!.id : undefined,
|
||
ref_text: ttsMode === "voiceclone" ? refText : undefined,
|
||
language: textLang,
|
||
speed: ttsMode === "voiceclone" ? speed : undefined,
|
||
instruct_text: ttsMode === "voiceclone" ? emotionToInstruct[emotion] || "" : undefined,
|
||
};
|
||
await generateAudio(params);
|
||
};
|
||
|
||
// 生成视频
|
||
const handleGenerate = async () => {
|
||
if (selectedMaterials.length === 0 || !text.trim()) {
|
||
toast.error("请先选择素材并填写文案");
|
||
return;
|
||
}
|
||
|
||
if (!selectedAudio) {
|
||
toast.error("请先生成并选中配音");
|
||
return;
|
||
}
|
||
|
||
if (enableBgm && !selectedBgmId) {
|
||
toast.error("请选择背景音乐");
|
||
return;
|
||
}
|
||
|
||
setGeneratedVideo(null);
|
||
|
||
try {
|
||
// 查找选中的素材对象以获取路径
|
||
const firstMaterialObj = materials.find((m) => m.id === selectedMaterials[0]);
|
||
if (!firstMaterialObj) {
|
||
toast.error("素材数据异常");
|
||
return;
|
||
}
|
||
|
||
// 构建请求参数 - 使用预生成配音
|
||
const payload: Record<string, unknown> = {
|
||
material_path: firstMaterialObj.path,
|
||
text: selectedAudio.text || text,
|
||
generated_audio_id: selectedAudio.id,
|
||
language: selectedAudio.language || textLang,
|
||
lipsync_model: lipsyncModelMode,
|
||
title: videoTitle.trim() || undefined,
|
||
enable_subtitles: true,
|
||
output_aspect_ratio: outputAspectRatio,
|
||
};
|
||
|
||
// 多素材
|
||
if (selectedMaterials.length > 1) {
|
||
const timelineOrderedIds = timelineSegments
|
||
.map((seg) => seg.materialId)
|
||
.filter((id, index, arr) => arr.indexOf(id) === index);
|
||
const orderedMaterialIds = [
|
||
...timelineOrderedIds.filter((id) => selectedMaterials.includes(id)),
|
||
...selectedMaterials.filter((id) => !timelineOrderedIds.includes(id)),
|
||
];
|
||
|
||
const materialPaths = orderedMaterialIds
|
||
.map((id) => materials.find((x) => x.id === id)?.path)
|
||
.filter((path): path is string => !!path);
|
||
|
||
if (materialPaths.length === 0) {
|
||
toast.error("多素材解析失败,请刷新素材后重试");
|
||
return;
|
||
}
|
||
|
||
payload.material_paths = materialPaths;
|
||
payload.material_path = materialPaths[0];
|
||
|
||
// 发送自定义时间轴分配
|
||
const assignments = toCustomAssignments();
|
||
if (assignments.length > 0) {
|
||
const assignmentPaths = assignments
|
||
.map((a) => a.material_path)
|
||
.filter((path): path is string => !!path);
|
||
|
||
if (assignmentPaths.length === assignments.length) {
|
||
// 以时间轴可见段为准:超出时间轴的素材不会参与本次生成
|
||
payload.material_paths = assignmentPaths;
|
||
payload.material_path = assignmentPaths[0];
|
||
}
|
||
payload.custom_assignments = assignments;
|
||
} else {
|
||
console.warn(
|
||
"[Timeline] custom_assignments 为空,回退后端自动分配",
|
||
{ materials: materialPaths.length }
|
||
);
|
||
}
|
||
}
|
||
|
||
// 单素材 + 截取范围
|
||
const singleSeg = timelineSegments[0];
|
||
if (
|
||
selectedMaterials.length === 1
|
||
&& singleSeg
|
||
&& (singleSeg.sourceStart > 0 || singleSeg.sourceEnd > 0)
|
||
) {
|
||
payload.custom_assignments = toCustomAssignments();
|
||
}
|
||
|
||
if (selectedSubtitleStyleId) {
|
||
payload.subtitle_style_id = selectedSubtitleStyleId;
|
||
}
|
||
|
||
if (subtitleFontSize) {
|
||
payload.subtitle_font_size = Math.round(subtitleFontSize);
|
||
}
|
||
|
||
if (videoTitle.trim() && selectedTitleStyleId) {
|
||
payload.title_style_id = selectedTitleStyleId;
|
||
}
|
||
|
||
if (videoTitle.trim() && titleFontSize) {
|
||
payload.title_font_size = Math.round(titleFontSize);
|
||
}
|
||
|
||
if (videoTitle.trim() || videoSecondaryTitle.trim()) {
|
||
payload.title_display_mode = titleDisplayMode;
|
||
if (titleDisplayMode === "short") {
|
||
payload.title_duration = DEFAULT_SHORT_TITLE_DURATION;
|
||
}
|
||
}
|
||
|
||
if (videoTitle.trim()) {
|
||
payload.title_top_margin = Math.round(titleTopMargin);
|
||
}
|
||
|
||
if (videoSecondaryTitle.trim()) {
|
||
payload.secondary_title = videoSecondaryTitle.trim();
|
||
if (selectedSecondaryTitleStyleId) {
|
||
payload.secondary_title_style_id = selectedSecondaryTitleStyleId;
|
||
}
|
||
if (secondaryTitleFontSize) {
|
||
payload.secondary_title_font_size = Math.round(secondaryTitleFontSize);
|
||
}
|
||
payload.secondary_title_top_margin = Math.round(secondaryTitleTopMargin);
|
||
}
|
||
|
||
payload.subtitle_bottom_margin = Math.round(subtitleBottomMargin);
|
||
|
||
if (enableBgm && selectedBgmId) {
|
||
payload.bgm_id = selectedBgmId;
|
||
payload.bgm_volume = 0.2;
|
||
}
|
||
|
||
// 创建生成任务
|
||
const { data: res } = await api.post<ApiResponse<{ task_id: string }>>(
|
||
"/api/videos/generate",
|
||
payload
|
||
);
|
||
|
||
const taskId = unwrap(res).task_id;
|
||
|
||
// 保存任务ID到 localStorage,以便页面切换后恢复
|
||
localStorage.setItem(`vigent_${storageKey}_current_task`, taskId);
|
||
|
||
// 使用全局 TaskContext 开始任务
|
||
startTask(taskId);
|
||
} catch (error) {
|
||
console.error("生成失败:", error);
|
||
}
|
||
};
|
||
|
||
const handleSelectRefAudio = (audio: RefAudio) => {
|
||
setSelectedRefAudio(audio);
|
||
setRefText(audio.ref_text);
|
||
};
|
||
|
||
const handlePreviewMaterial = (path: string) => {
|
||
setPreviewMaterial(resolveMediaUrl(path));
|
||
};
|
||
|
||
const handleSelectVideo = (video: GeneratedVideo) => {
|
||
setSelectedVideoId(video.id);
|
||
setGeneratedVideo(resolveMediaUrl(video.path));
|
||
};
|
||
|
||
const registerMaterialRef = (id: string, el: HTMLDivElement | null) => {
|
||
materialItemRefs.current[id] = el;
|
||
};
|
||
|
||
const registerBgmItemRef = (id: string, el: HTMLDivElement | null) => {
|
||
bgmItemRefs.current[id] = el;
|
||
};
|
||
|
||
const registerVideoRef = (id: string, el: HTMLDivElement | null) => {
|
||
videoItemRefs.current[id] = el;
|
||
};
|
||
|
||
return {
|
||
apiBase,
|
||
registerMaterialRef,
|
||
previewMaterial,
|
||
setPreviewMaterial,
|
||
materials,
|
||
fetchError,
|
||
isFetching,
|
||
lastMaterialCount,
|
||
isUploading,
|
||
uploadProgress,
|
||
uploadError,
|
||
setUploadError,
|
||
fetchMaterials,
|
||
deleteMaterial,
|
||
handleUpload,
|
||
selectedMaterials,
|
||
toggleMaterial,
|
||
handlePreviewMaterial,
|
||
editingMaterialId,
|
||
editMaterialName,
|
||
setEditMaterialName,
|
||
startMaterialEditing,
|
||
saveMaterialEditing,
|
||
cancelMaterialEditing,
|
||
text,
|
||
setText,
|
||
extractModalOpen,
|
||
setExtractModalOpen,
|
||
rewriteModalOpen,
|
||
setRewriteModalOpen,
|
||
handleGenerateMeta,
|
||
isGeneratingMeta,
|
||
handleTranslate,
|
||
isTranslating,
|
||
originalText,
|
||
handleRestoreOriginal,
|
||
savedScripts,
|
||
handleSaveScript,
|
||
deleteSavedScript,
|
||
showStylePreview,
|
||
setShowStylePreview,
|
||
videoTitle,
|
||
titleInput,
|
||
titleStyles,
|
||
selectedTitleStyleId,
|
||
setSelectedTitleStyleId,
|
||
titleFontSize,
|
||
setTitleFontSize,
|
||
setTitleSizeLocked,
|
||
videoSecondaryTitle,
|
||
secondaryTitleInput,
|
||
selectedSecondaryTitleStyleId,
|
||
setSelectedSecondaryTitleStyleId,
|
||
secondaryTitleFontSize,
|
||
setSecondaryTitleFontSize,
|
||
setSecondaryTitleSizeLocked,
|
||
secondaryTitleTopMargin,
|
||
setSecondaryTitleTopMargin,
|
||
subtitleStyles,
|
||
selectedSubtitleStyleId,
|
||
setSelectedSubtitleStyleId,
|
||
subtitleFontSize,
|
||
setSubtitleFontSize,
|
||
setSubtitleSizeLocked,
|
||
titleTopMargin,
|
||
setTitleTopMargin,
|
||
titleDisplayMode,
|
||
setTitleDisplayMode,
|
||
subtitleBottomMargin,
|
||
setSubtitleBottomMargin,
|
||
outputAspectRatio,
|
||
setOutputAspectRatio,
|
||
lipsyncModelMode,
|
||
setLipsyncModelMode,
|
||
resolveAssetUrl,
|
||
getFontFormat,
|
||
buildTextShadow,
|
||
materialDimensions,
|
||
materialPosterUrl,
|
||
ttsMode,
|
||
setTtsMode,
|
||
voices: VOICES[textLang] || VOICES["zh-CN"],
|
||
voice,
|
||
setVoice,
|
||
textLang,
|
||
refAudios,
|
||
selectedRefAudio,
|
||
handleSelectRefAudio,
|
||
isUploadingRef,
|
||
uploadRefError,
|
||
setUploadRefError,
|
||
uploadRefAudio,
|
||
fetchRefAudios,
|
||
playingAudioId,
|
||
togglePlayPreview,
|
||
editingAudioId,
|
||
editName,
|
||
setEditName,
|
||
startEditing,
|
||
saveEditing,
|
||
cancelEditing,
|
||
deleteRefAudio,
|
||
retranscribeRefAudio,
|
||
retranscribingId,
|
||
recordedBlob,
|
||
isRecording,
|
||
recordingTime,
|
||
startRecording,
|
||
stopRecording,
|
||
useRecording,
|
||
discardRecording,
|
||
formatRecordingTime,
|
||
bgmList,
|
||
bgmLoading,
|
||
bgmError,
|
||
enableBgm,
|
||
setEnableBgm,
|
||
fetchBgmList,
|
||
selectedBgmId,
|
||
setSelectedBgmId,
|
||
playingBgmId,
|
||
toggleBgmPreview,
|
||
bgmVolume,
|
||
setBgmVolume,
|
||
bgmListContainerRef,
|
||
registerBgmItemRef,
|
||
currentTask,
|
||
isGenerating,
|
||
handleGenerate,
|
||
generatedVideo,
|
||
generatedVideos,
|
||
selectedVideoId,
|
||
handleSelectVideo,
|
||
deleteVideo,
|
||
fetchGeneratedVideos,
|
||
registerVideoRef,
|
||
formatDate,
|
||
generatedAudios,
|
||
selectedAudio,
|
||
selectedAudioId,
|
||
isGeneratingAudio,
|
||
audioTask,
|
||
fetchGeneratedAudios,
|
||
handleGenerateAudio,
|
||
deleteAudio,
|
||
renameAudio,
|
||
selectAudio,
|
||
speed,
|
||
setSpeed,
|
||
emotion,
|
||
setEmotion,
|
||
timelineSegments,
|
||
reorderSegments,
|
||
setSourceRange,
|
||
clipTrimmerOpen,
|
||
setClipTrimmerOpen,
|
||
clipTrimmerSegmentId,
|
||
setClipTrimmerSegmentId,
|
||
};
|
||
};
|