@@ -4,37 +4,43 @@
|
||||
* 模板选择已删除,模板在后台自动加载/选择(后端 API 兼容)。
|
||||
* 新增片段数量输入(默认3),系统按片段数智能匹配素材。
|
||||
*/
|
||||
import React, { useMemo, useState, useEffect, useRef, useCallback } from "react"
|
||||
import { message } from "antd"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import type { VoiceClone } from "@/api/voice-clone"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { useCloneProgress } from "@/hooks/useCloneProgress"
|
||||
import CloneModal from "@/components/voice/CloneModal"
|
||||
import GenerateHeader from "./components/GenerateHeader"
|
||||
import FrontendPreviewPlayer from "./components/FrontendPreviewPlayer"
|
||||
import CanvasPreviewGrid from "./components/CanvasPreviewGrid"
|
||||
import PreviewCountModal from "./components/PreviewCountModal"
|
||||
import GenerateStepsBar from "./components/GenerateStepsBar"
|
||||
import GenerateStepContent from "./components/GenerateStepContent"
|
||||
import GenerateStepActions from "./components/GenerateStepActions"
|
||||
import { useGenerateFormState } from "./hooks/useGenerateFormState"
|
||||
import { useStepNavigation } from "./hooks/useStepNavigation"
|
||||
import { useGenerateVideo } from "./hooks/useGenerateVideo"
|
||||
import React, {
|
||||
useMemo,
|
||||
useState,
|
||||
useEffect,
|
||||
useRef,
|
||||
useCallback,
|
||||
} from "react";
|
||||
import { message } from "antd";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import type { VoiceClone } from "@/api/voice-clone";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useCloneProgress } from "@/hooks/useCloneProgress";
|
||||
import CloneModal from "@/components/voice/CloneModal";
|
||||
import GenerateHeader from "./components/GenerateHeader";
|
||||
import FrontendPreviewPlayer from "./components/FrontendPreviewPlayer";
|
||||
import CanvasPreviewGrid from "./components/CanvasPreviewGrid";
|
||||
import PreviewCountModal from "./components/PreviewCountModal";
|
||||
import GenerateStepsBar from "./components/GenerateStepsBar";
|
||||
import GenerateStepContent from "./components/GenerateStepContent";
|
||||
import GenerateStepActions from "./components/GenerateStepActions";
|
||||
import { useGenerateFormState } from "./hooks/useGenerateFormState";
|
||||
import { useStepNavigation } from "./hooks/useStepNavigation";
|
||||
import { useGenerateVideo } from "./hooks/useGenerateVideo";
|
||||
|
||||
import { usePreviewAssets } from "./hooks/usePreviewAssets"
|
||||
import { useBatchVariantPlans } from "./hooks/useBatchVariantPlans"
|
||||
import { useVariantVoicePreview } from "./hooks/useVariantVoicePreview"
|
||||
import { useTitleStyleUpdaters } from "./hooks/useStep4Title/useTitleStyleUpdaters"
|
||||
import { getAssetsByKind } from "@/api/assets"
|
||||
import { previewTts } from "@/api/tts"
|
||||
import "./generate.css"
|
||||
import { usePreviewAssets } from "./hooks/usePreviewAssets";
|
||||
import { useBatchVariantPlans } from "./hooks/useBatchVariantPlans";
|
||||
import { useVariantVoicePreview } from "./hooks/useVariantVoicePreview";
|
||||
import { useTitleStyleUpdaters } from "./hooks/useStep4Title/useTitleStyleUpdaters";
|
||||
import { getAssetsByKind } from "@/api/assets";
|
||||
import { previewTts } from "@/api/tts";
|
||||
import "./generate.css";
|
||||
|
||||
const GeneratePage: React.FC = () => {
|
||||
const navigate = useNavigate()
|
||||
const navigate = useNavigate();
|
||||
|
||||
/* ── 表单状态 ── */
|
||||
const formState = useGenerateFormState()
|
||||
const formState = useGenerateFormState();
|
||||
const {
|
||||
currentStep,
|
||||
setCurrentStep,
|
||||
@@ -83,104 +89,120 @@ const GeneratePage: React.FC = () => {
|
||||
setPreviewCovers,
|
||||
selectedVariantIds,
|
||||
setSelectedVariantIds,
|
||||
} = formState
|
||||
} = formState;
|
||||
|
||||
const isBatch = previewCount > 1
|
||||
const isBatch = previewCount > 1;
|
||||
|
||||
/* ── 配音选择同步 ── */
|
||||
const prevVoiceSyncRef = useRef({
|
||||
voice: selectedVoice,
|
||||
batch: isBatch,
|
||||
perVideo: voiceModePerVideo,
|
||||
})
|
||||
});
|
||||
useEffect(() => {
|
||||
const prev = prevVoiceSyncRef.current
|
||||
const voiceChanged = prev.voice !== selectedVoice
|
||||
const modeChanged = prev.batch !== isBatch || prev.perVideo !== voiceModePerVideo
|
||||
prevVoiceSyncRef.current = { voice: selectedVoice, batch: isBatch, perVideo: voiceModePerVideo }
|
||||
if (!voiceChanged && !modeChanged) return
|
||||
if (!isBatch) return
|
||||
const prev = prevVoiceSyncRef.current;
|
||||
const voiceChanged = prev.voice !== selectedVoice;
|
||||
const modeChanged =
|
||||
prev.batch !== isBatch || prev.perVideo !== voiceModePerVideo;
|
||||
prevVoiceSyncRef.current = {
|
||||
voice: selectedVoice,
|
||||
batch: isBatch,
|
||||
perVideo: voiceModePerVideo,
|
||||
};
|
||||
if (!voiceChanged && !modeChanged) return;
|
||||
if (!isBatch) return;
|
||||
if (!voiceModePerVideo) {
|
||||
setVoiceLibraryIds((prevIds) => (prevIds || []).map((id) => id || selectedVoice))
|
||||
setVoiceLibraryIds((prevIds) =>
|
||||
(prevIds || []).map((id) => id || selectedVoice),
|
||||
);
|
||||
} else if (voiceChanged) {
|
||||
setVoiceLibraryIds((prevIds) =>
|
||||
(prevIds || []).map((id, i) => (i === 0 ? selectedVoice : id)),
|
||||
)
|
||||
);
|
||||
}
|
||||
}, [selectedVoice, isBatch, voiceModePerVideo, setVoiceLibraryIds])
|
||||
}, [selectedVoice, isBatch, voiceModePerVideo, setVoiceLibraryIds]);
|
||||
|
||||
/* ── 数量选择弹窗 ── */
|
||||
const [countModalOpen, setCountModalOpen] = useState(false)
|
||||
const [countModalOpen, setCountModalOpen] = useState(false);
|
||||
|
||||
/* ── 标题样式回调 ── */
|
||||
const styleUpdaters = useTitleStyleUpdaters({
|
||||
titleSettings,
|
||||
onTitleSettingsChange: setTitleSettings,
|
||||
})
|
||||
});
|
||||
|
||||
/* ── 配音素材库(TTS 试听)── */
|
||||
const { data: voiceMaterials = [] } = useQuery({
|
||||
queryKey: ["assets", "voice"],
|
||||
queryFn: () => getAssetsByKind("voice", { limit: 50 }),
|
||||
})
|
||||
});
|
||||
|
||||
const [previewVoiceAudioUrl, setPreviewVoiceAudioUrl] = useState<string | null>(null)
|
||||
const ttsAbortRef = useRef<AbortController | null>(null)
|
||||
const variant0Title = isBatch ? previewTitles?.[0] || "" : ""
|
||||
const [previewVoiceAudioUrl, setPreviewVoiceAudioUrl] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const ttsAbortRef = useRef<AbortController | null>(null);
|
||||
const variant0Title = isBatch ? previewTitles?.[0] || "" : "";
|
||||
|
||||
useEffect(() => {
|
||||
if (isBatch) return
|
||||
const voiceAsset = voiceMaterials.find((m) => m.id === selectedVoice)
|
||||
if (isBatch) return;
|
||||
const voiceAsset = voiceMaterials.find((m) => m.id === selectedVoice);
|
||||
if (voiceAsset?.file_url) {
|
||||
setPreviewVoiceAudioUrl(voiceAsset.file_url)
|
||||
return
|
||||
setPreviewVoiceAudioUrl(voiceAsset.file_url);
|
||||
return;
|
||||
}
|
||||
|
||||
const ttsTitle = titleSettings.title
|
||||
const voiceId = selectedClonedVoice || selectedVoice
|
||||
const ttsTitle = titleSettings.title;
|
||||
const voiceId = selectedClonedVoice || selectedVoice;
|
||||
if (!voiceId || !ttsTitle) {
|
||||
setPreviewVoiceAudioUrl(null)
|
||||
return
|
||||
setPreviewVoiceAudioUrl(null);
|
||||
return;
|
||||
}
|
||||
|
||||
ttsAbortRef.current?.abort()
|
||||
const controller = new AbortController()
|
||||
ttsAbortRef.current = controller
|
||||
let cancelled = false
|
||||
ttsAbortRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
ttsAbortRef.current = controller;
|
||||
let cancelled = false;
|
||||
|
||||
previewTts({ text: ttsTitle, voice_id: voiceId })
|
||||
.then((res) => {
|
||||
if (!cancelled && res.audio_url) {
|
||||
setPreviewVoiceAudioUrl(res.audio_url)
|
||||
setPreviewVoiceAudioUrl(res.audio_url);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) {
|
||||
console.warn("[预览配音生成失败]", err)
|
||||
setPreviewVoiceAudioUrl(null)
|
||||
console.warn("[预览配音生成失败]", err);
|
||||
setPreviewVoiceAudioUrl(null);
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
controller.abort()
|
||||
}
|
||||
}, [selectedVoice, selectedClonedVoice, titleSettings.title, variant0Title, isBatch, voiceMaterials])
|
||||
cancelled = true;
|
||||
controller.abort();
|
||||
};
|
||||
}, [
|
||||
selectedVoice,
|
||||
selectedClonedVoice,
|
||||
titleSettings.title,
|
||||
variant0Title,
|
||||
isBatch,
|
||||
voiceMaterials,
|
||||
]);
|
||||
|
||||
/* ── 克隆声音 ── */
|
||||
const { addClone } = useCloneProgress()
|
||||
const { addClone } = useCloneProgress();
|
||||
|
||||
const handleCloneSuccess = (voice: VoiceClone) => {
|
||||
addClone(voice)
|
||||
setCloneModalOpen(false)
|
||||
message.success("音色克隆成功!")
|
||||
}
|
||||
addClone(voice);
|
||||
setCloneModalOpen(false);
|
||||
message.success("音色克隆成功!");
|
||||
};
|
||||
|
||||
/* ── 素材 ID 列表 ── */
|
||||
const previewAssetIds = useMemo(
|
||||
() => (materialMode === "auto" ? smartSelectedIds : selectedMaterials),
|
||||
[materialMode, smartSelectedIds, selectedMaterials],
|
||||
)
|
||||
);
|
||||
|
||||
/* ── BGM 配置 ── */
|
||||
const bgmConfig = useMemo(
|
||||
@@ -189,23 +211,25 @@ const GeneratePage: React.FC = () => {
|
||||
music_id: "",
|
||||
}),
|
||||
[bgm],
|
||||
)
|
||||
);
|
||||
|
||||
/* ── 加载素材详情 ── */
|
||||
const previewAssetsEnabled = previewAssetIds.length > 0
|
||||
const previewAssetsEnabled = previewAssetIds.length > 0;
|
||||
const {
|
||||
assets: previewAssets,
|
||||
ready: previewAssetsReady,
|
||||
ensureAssets,
|
||||
} = usePreviewAssets(previewAssetIds, previewAssetsEnabled)
|
||||
} = usePreviewAssets(previewAssetIds, previewAssetsEnabled);
|
||||
|
||||
/* ── 预览就绪 ── */
|
||||
// #1899: 不再依赖 currentTemplate,素材加载完即可预览
|
||||
const previewReady = previewAssetsReady
|
||||
const previewReady = previewAssetsReady;
|
||||
|
||||
/* ── 批量变体真实片段(#1744) ── */
|
||||
const batchVoiceLibraryId =
|
||||
voiceMode === "clone" ? selectedClonedVoice || selectedVoice || "" : selectedVoice || ""
|
||||
voiceMode === "clone"
|
||||
? selectedClonedVoice || selectedVoice || ""
|
||||
: selectedVoice || "";
|
||||
const {
|
||||
clipsByVariant: variantClips,
|
||||
planIdsByVariant: variantPlanIds,
|
||||
@@ -222,7 +246,7 @@ const GeneratePage: React.FC = () => {
|
||||
voiceLibraryId: batchVoiceLibraryId,
|
||||
voiceLibraryIds: voiceLibraryIds || [],
|
||||
voiceModePerVideo,
|
||||
})
|
||||
});
|
||||
|
||||
/* ── 批量变体配音预览 URL ── */
|
||||
const variantVoiceAudioUrls = useVariantVoicePreview({
|
||||
@@ -233,29 +257,33 @@ const GeneratePage: React.FC = () => {
|
||||
clonedVoiceId: selectedClonedVoice || "",
|
||||
variantVoiceIds: voiceLibraryIds || [],
|
||||
titles: previewTitles || [],
|
||||
})
|
||||
});
|
||||
|
||||
/* ── 变体 clips 引用素材补拉 ── */
|
||||
const clipAssetIds = useMemo(() => {
|
||||
if (!isBatch || !variantClips?.length) return []
|
||||
const ids = new Set<string>()
|
||||
variantClips.forEach((list) => list.forEach((c) => c.asset_id && ids.add(c.asset_id)))
|
||||
return Array.from(ids)
|
||||
}, [isBatch, variantClips])
|
||||
if (!isBatch || !variantClips?.length) return [];
|
||||
const ids = new Set<string>();
|
||||
variantClips.forEach((list) =>
|
||||
list.forEach((c) => c.asset_id && ids.add(c.asset_id)),
|
||||
);
|
||||
return Array.from(ids);
|
||||
}, [isBatch, variantClips]);
|
||||
useEffect(() => {
|
||||
if (clipAssetIds.length > 0) void ensureAssets(clipAssetIds)
|
||||
}, [clipAssetIds, ensureAssets])
|
||||
if (clipAssetIds.length > 0) void ensureAssets(clipAssetIds);
|
||||
}, [clipAssetIds, ensureAssets]);
|
||||
|
||||
/* ── 勾选变体 ── */
|
||||
const toggleVariantSelect = useCallback(
|
||||
(index: number) => {
|
||||
setSelectedVariantIds((prev) => {
|
||||
const list = prev || []
|
||||
return list.includes(index) ? list.filter((i) => i !== index) : [...list, index].sort()
|
||||
})
|
||||
const list = prev || [];
|
||||
return list.includes(index)
|
||||
? list.filter((i) => i !== index)
|
||||
: [...list, index].sort();
|
||||
});
|
||||
},
|
||||
[setSelectedVariantIds],
|
||||
)
|
||||
);
|
||||
|
||||
/* ── 视频生成核心逻辑 ── */
|
||||
const {
|
||||
@@ -297,31 +325,37 @@ const GeneratePage: React.FC = () => {
|
||||
variantCoverUrls: previewCovers,
|
||||
selectedVariantIndexes: isBatch ? selectedVariantIds : undefined,
|
||||
onGenerationSuccess: () => {
|
||||
setPreviewTaskId(null)
|
||||
setStoredSourceEditPlanId(null)
|
||||
setPreviewTaskId(null);
|
||||
setStoredSourceEditPlanId(null);
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
/* ── 数量弹窗确认 ── */
|
||||
const handleCountConfirm = useCallback(
|
||||
(count: number) => {
|
||||
setPreviewCount(count)
|
||||
setCountModalOpen(false)
|
||||
setPreviewCount(count);
|
||||
setCountModalOpen(false);
|
||||
setPreviewTitles((prev) => {
|
||||
const list = prev || []
|
||||
const base = list[0] || titleSettings.title || ""
|
||||
return Array.from({ length: count }, (_, i) => list[i] ?? (i === 0 ? base : ""))
|
||||
})
|
||||
const list = prev || [];
|
||||
const base = list[0] || titleSettings.title || "";
|
||||
return Array.from(
|
||||
{ length: count },
|
||||
(_, i) => list[i] ?? (i === 0 ? base : ""),
|
||||
);
|
||||
});
|
||||
setVoiceLibraryIds((prev) => {
|
||||
const list = prev || []
|
||||
return Array.from({ length: count }, (_, i) => list[i] ?? selectedVoice ?? "")
|
||||
})
|
||||
const list = prev || [];
|
||||
return Array.from(
|
||||
{ length: count },
|
||||
(_, i) => list[i] ?? selectedVoice ?? "",
|
||||
);
|
||||
});
|
||||
setPreviewCovers((prev) => {
|
||||
const list = prev || []
|
||||
return Array.from({ length: count }, (_, i) => list[i] ?? "")
|
||||
})
|
||||
setSelectedVariantIds(Array.from({ length: count }, (_, i) => i))
|
||||
setCurrentStep(2)
|
||||
const list = prev || [];
|
||||
return Array.from({ length: count }, (_, i) => list[i] ?? "");
|
||||
});
|
||||
setSelectedVariantIds(Array.from({ length: count }, (_, i) => i));
|
||||
setCurrentStep(2);
|
||||
},
|
||||
[
|
||||
setPreviewCount,
|
||||
@@ -333,33 +367,33 @@ const GeneratePage: React.FC = () => {
|
||||
titleSettings.title,
|
||||
selectedVoice,
|
||||
],
|
||||
)
|
||||
);
|
||||
|
||||
/* ── 步骤3「确认生成视频」:校验通过 → 创建正式生成任务 → 跳步骤4看实时进展 ── */
|
||||
const handleConfirmGenerate = useCallback(async () => {
|
||||
if (isBatch) {
|
||||
if (selectedVariantIds.length === 0) {
|
||||
message.warning("请至少勾选一个视频")
|
||||
return
|
||||
message.warning("请至少勾选一个视频");
|
||||
return;
|
||||
}
|
||||
const missing = selectedVariantIds.some((i) => !previewTitles[i]?.trim())
|
||||
const missing = selectedVariantIds.some((i) => !previewTitles[i]?.trim());
|
||||
if (missing) {
|
||||
message.warning("请为每个勾选的视频输入标题")
|
||||
return
|
||||
message.warning("请为每个勾选的视频输入标题");
|
||||
return;
|
||||
}
|
||||
} else if (!titleSettings.title?.trim()) {
|
||||
if (!titleSettings.aiAutoSelect) {
|
||||
message.warning("请先选择或输入标题")
|
||||
return
|
||||
message.warning("请先选择或输入标题");
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!previewReady) {
|
||||
message.warning("预览素材正在加载,请稍候")
|
||||
return
|
||||
message.warning("预览素材正在加载,请稍候");
|
||||
return;
|
||||
}
|
||||
const ok = await handleGenerate()
|
||||
const ok = await handleGenerate();
|
||||
if (ok) {
|
||||
setCurrentStep(4)
|
||||
setCurrentStep(4);
|
||||
}
|
||||
}, [
|
||||
isBatch,
|
||||
@@ -370,7 +404,7 @@ const GeneratePage: React.FC = () => {
|
||||
previewReady,
|
||||
handleGenerate,
|
||||
setCurrentStep,
|
||||
])
|
||||
]);
|
||||
|
||||
/* ── 步骤导航 ── */
|
||||
const { goNext, goPrev } = useStepNavigation({
|
||||
@@ -382,16 +416,16 @@ const GeneratePage: React.FC = () => {
|
||||
titleSettings,
|
||||
generated,
|
||||
onOpenCountModal: () => setCountModalOpen(true),
|
||||
})
|
||||
});
|
||||
|
||||
/* ── 最终成片 ── */
|
||||
const finalVideo = generatedVideos[0]
|
||||
const finalVideo = generatedVideos[0];
|
||||
|
||||
/* ── 布局 class ── */
|
||||
const layoutClassName = useMemo(() => {
|
||||
if (currentStep === 3) return "xx-generate-layout step4-layout"
|
||||
return "xx-generate-layout full-width"
|
||||
}, [currentStep])
|
||||
if (currentStep === 3) return "xx-generate-layout step4-layout";
|
||||
return "xx-generate-layout full-width";
|
||||
}, [currentStep]);
|
||||
|
||||
/* ================================================================
|
||||
渲染
|
||||
@@ -401,7 +435,10 @@ const GeneratePage: React.FC = () => {
|
||||
<div className="xx-generate-page">
|
||||
<GenerateHeader fromEditPlan={!!editPlanId} />
|
||||
|
||||
<GenerateStepsBar currentStep={currentStep} onStepClick={setCurrentStep} />
|
||||
<GenerateStepsBar
|
||||
currentStep={currentStep}
|
||||
onStepClick={setCurrentStep}
|
||||
/>
|
||||
|
||||
<div className={layoutClassName}>
|
||||
{/* ════ 步骤3:左侧预览大区域 ════ */}
|
||||
@@ -419,7 +456,8 @@ const GeneratePage: React.FC = () => {
|
||||
size: titleSettings.size,
|
||||
font: titleSettings.font,
|
||||
color: titleSettings.color,
|
||||
position: titleSettings.position as "top" | "center" | "bottom" | "custom",
|
||||
position: titleSettings.position as
|
||||
"top" | "center" | "bottom" | "custom",
|
||||
bold: titleSettings.bold,
|
||||
italic: titleSettings.italic,
|
||||
stroke: titleSettings.stroke,
|
||||
@@ -433,7 +471,12 @@ const GeneratePage: React.FC = () => {
|
||||
<div className="xx-form-section">
|
||||
<div className="xx-preview-header">
|
||||
<h3>🎬 {previewCount} 个视频预览</h3>
|
||||
<span style={{ fontSize: 13, color: "var(--text-secondary, #666)" }}>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: "var(--text-secondary, #666)",
|
||||
}}
|
||||
>
|
||||
实时预览,勾选要生成的视频
|
||||
</span>
|
||||
</div>
|
||||
@@ -556,11 +599,24 @@ const GeneratePage: React.FC = () => {
|
||||
}}
|
||||
poster={finalVideo.thumbnail_url || undefined}
|
||||
/>
|
||||
<div style={{ display: "flex", gap: 8, marginTop: 12, justifyContent: "center" }}>
|
||||
<button className="xx-btn xx-btn-ghost xx-btn-sm" onClick={handleDownload}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: 8,
|
||||
marginTop: 12,
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
className="xx-btn xx-btn-ghost xx-btn-sm"
|
||||
onClick={handleDownload}
|
||||
>
|
||||
⬇️ 下载
|
||||
</button>
|
||||
<button className="xx-btn xx-btn-ghost xx-btn-sm" onClick={handleShare}>
|
||||
<button
|
||||
className="xx-btn xx-btn-ghost xx-btn-sm"
|
||||
onClick={handleShare}
|
||||
>
|
||||
🔗 分享
|
||||
</button>
|
||||
<button
|
||||
@@ -602,8 +658,7 @@ const GeneratePage: React.FC = () => {
|
||||
onSuccess={handleCloneSuccess}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default GeneratePage
|
||||
);
|
||||
};
|
||||
|
||||
export default GeneratePage;
|
||||
|
||||
Reference in New Issue
Block a user