@@ -4,14 +4,15 @@
* 左右布局:左侧 generate-form + 右侧 generate-preview
*
* 架构:
* - 右侧展示生成的视频,内联播放
* -
* - 步骤 4-6 右侧显示 FrontendPreviewPlayer 实时预览
* - 步骤 7 右侧内联播放生成的最终视频
* - 点"确认生成"时调用 createGenerationTask 创建一次服务器渲染任务
*/
import React , { useMemo } from "react"
import React , { useMemo , useState , useEffect , useRef } 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"
@@ -19,6 +20,7 @@ import {
calculateTotalVideoDuration ,
estimateTotalVideoDuration ,
} from "./utils/calculateTotalVideoDuration"
import FrontendPreviewPlayer from "./components/FrontendPreviewPlayer"
import GenerateStepsBar from "./components/GenerateStepsBar"
import GenerateStepContent from "./components/GenerateStepContent"
import GenerateStepActions from "./components/GenerateStepActions"
@@ -27,6 +29,8 @@ import { useStepNavigation } from "./hooks/useStepNavigation"
import { useGenerateVideo } from "./hooks/useGenerateVideo"
import { usePreviewAssets } from "./hooks/usePreviewAssets"
import { useTitleStyleUpdaters } from "./hooks/useStep4Title/useTitleStyleUpdaters"
import { getAssetsByKind } from "@/api/assets"
import { previewTts } from "@/api/tts"
import "./generate.css"
const GeneratePage : React.FC = ( ) = > {
@@ -78,6 +82,53 @@ const GeneratePage: React.FC = () => {
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 )
useEffect ( ( ) = > {
const voiceAsset = voiceMaterials . find ( ( m ) = > m . id === selectedVoice )
if ( voiceAsset ? . file_url ) {
setPreviewVoiceAudioUrl ( voiceAsset . file_url )
return
}
const voiceId = selectedClonedVoice || selectedVoice
if ( ! voiceId || ! titleSettings . title ) {
setPreviewVoiceAudioUrl ( null )
return
}
ttsAbortRef . current ? . abort ( )
const controller = new AbortController ( )
ttsAbortRef . current = controller
let cancelled = false
previewTts ( { text : titleSettings.title , voice_id : voiceId } )
. then ( ( res ) = > {
if ( ! cancelled && res . audio_url ) {
setPreviewVoiceAudioUrl ( res . audio_url )
}
} )
. catch ( ( err ) = > {
if ( ! cancelled ) {
console . warn ( "[预览配音生成失败]" , err )
setPreviewVoiceAudioUrl ( null )
}
} )
return ( ) = > {
cancelled = true
controller . abort ( )
}
// eslint-disable-next-line react-hooks/exhaustive-deps
} , [ selectedVoice , selectedClonedVoice , titleSettings . title , voiceMaterials ] )
/* ── 克隆声音 ── */
const { clones : clonedVoices , addClone , hasProcessing } = useCloneProgress ( )
@@ -176,7 +227,7 @@ const GeneratePage: React.FC = () => {
< GenerateStepsBar currentStep = { currentStep } onStepClick = { setCurrentStep } / >
< div className = "xx-generate-layout" >
< div className = { ` xx-generate-layout ${ currentStep < 4 ? " full-width" : "" } ` } >
{ /* ════ 左侧:表单区 ════ */ }
< div className = "xx-generate-form" >
< GenerateStepContent
@@ -242,9 +293,29 @@ const GeneratePage: React.FC = () => {
/ >
< / div >
{ /* ════ 右侧:生成结果 视频 ════ */ }
{ /* ════ 右侧:步骤 4-6 实时预览,步骤 7 最终 视频 ════ */ }
< div className = "xx-generate-right-col" >
{ generated && generatedVideos . length > 0 ? (
{ currentStep >= 4 && currentStep <= 6 && ! ! currentTemplate && (
< FrontendPreviewPlayer
assets = { previewAssets }
template = { currentTemplate }
videoRatio = { videoRatio }
ready = { previewAssets . length > 0 }
voiceAudioUrl = { previewVoiceAudioUrl || undefined }
titleSettings = { {
title : titleSettings.title ,
size : titleSettings.size ,
font : titleSettings.font ,
color : titleSettings.color ,
position : titleSettings.position as "top" | "center" | "bottom" ,
bold : titleSettings.bold ,
italic : titleSettings.italic ,
stroke : titleSettings.stroke ,
shadow : titleSettings.shadow ,
} }
/ >
) }
{ currentStep === 7 && generated && generatedVideos . length > 0 && (
< div className = "xx-inline-video-player" >
< video
src = { generatedVideos [ 0 ] . download_url || generatedVideos [ 0 ] . file_url }
@@ -268,69 +339,6 @@ const GeneratePage: React.FC = () => {
< / button >
< / div >
< / div >
) : generating ? (
< div
className = "xx-inline-video-player"
style = { { textAlign : "center" , padding : "60px 20px" } }
>
< div style = { { fontSize : 48 , marginBottom : 16 } } > 🎬 < / div >
< div style = { { fontSize : 16 , fontWeight : 500 , marginBottom : 8 } } > 视 频 生 成 中 . . . < / div >
< div style = { { fontSize : 13 , color : "var(--text-secondary)" , marginBottom : 16 } } >
预 计 还 需 1 - 2 分 钟 , 请 稍 候
< / div >
< div
style = { {
maxWidth : 280 ,
margin : "0 auto" ,
height : 6 ,
background : "var(--bg-tertiary)" ,
borderRadius : 3 ,
overflow : "hidden" ,
} }
>
< div
style = { {
width : ` ${ Math . min ( Math . round ( progress ) , 100 ) } % ` ,
height : "100%" ,
background : "var(--primary-color, #6366f1)" ,
borderRadius : 3 ,
transition : "width 0.3s" ,
} }
/ >
< / div >
< div style = { { fontSize : 12 , color : "var(--text-tertiary)" , marginTop : 8 } } >
{ Math . round ( progress ) } %
< / div >
< / div >
) : generateError ? (
< div
className = "xx-inline-video-player"
style = { { textAlign : "center" , padding : "60px 20px" } }
>
< div style = { { fontSize : 48 , marginBottom : 16 } } > ❌ < / div >
< div style = { { fontSize : 16 , fontWeight : 500 , marginBottom : 8 , color : "#ef4444" } } >
生 成 失 败
< / div >
< div style = { { fontSize : 13 , color : "var(--text-secondary)" , marginBottom : 16 } } >
{ typeof generateError === "string" ? generateError : "请重试" }
< / div >
< button className = "xx-btn xx-btn-primary xx-btn-sm" onClick = { handleRetryGenerate } >
🔄 重 试
< / button >
< / div >
) : (
< div
className = "xx-inline-video-player"
style = { { textAlign : "center" , padding : "80px 20px" } }
>
< div style = { { fontSize : 48 , marginBottom : 16 , opacity : 0.5 } } > 🎥 < / div >
< div style = { { fontSize : 14 , color : "var(--text-secondary)" } } >
完 成 配 置 后 点 击 「 确 认 生 成 」
< / div >
< div style = { { fontSize : 12 , color : "var(--text-tertiary)" , marginTop : 4 } } >
生 成 的 视 频 将 在 这 里 展 示
< / div >
< / div >
) }
< / div >
< / div >