d11ca875c6
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 33s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 2m33s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 2m32s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 2m43s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 1m4s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 2m55s
CI/CD Pipeline / Build Staging API Image (push) Successful in 4m8s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m49s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 3m1s
AI Code Review / AI Code Review (pull_request) Failing after 4m44s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 35s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 48s
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 527h0m6s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 527h0m8s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 527h0m10s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 527h0m16s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 527h0m47s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 527h1m21s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 527h3m35s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 527h1m23s
CI/CD Pipeline / PR Build API Image (push) Failing after 527h3m39s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 527h4m26s
CI/CD Pipeline / Frontend Lint (push) Failing after 527h4m29s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 527h4m28s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 527h4m31s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Failing after 527h4m51s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 527h8m5s
CI/CD Pipeline / PR Build Web Image (push) Failing after 527h37m31s
387 lines
12 KiB
TypeScript
387 lines
12 KiB
TypeScript
/**
|
|
* 智能剪辑页面 — 前端实时预览架构
|
|
* 7 步向导:选择模板 → 素材 → 配音 → 标题 → 预览 → 封面 → 确认生成
|
|
* 左右布局:左侧 generate-form + 右侧 generate-preview
|
|
*
|
|
* 架构:
|
|
* - Step4+ 右侧预览面板使用 FrontendPreviewPlayer 实时播放素材片段
|
|
* - 标题样式编辑时 CSS 层实时叠加预览,所见即所得
|
|
* - 点"确认生成"时调用 createGenerationTask 创建一次服务器渲染任务
|
|
*/
|
|
import React, { useMemo, useState, useEffect, useRef } from "react"
|
|
import { Modal, 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 {
|
|
calculateTotalVideoDuration,
|
|
estimateTotalVideoDuration,
|
|
} from "./utils/calculateTotalVideoDuration"
|
|
import GenerateStepsBar from "./components/GenerateStepsBar"
|
|
import GenerateResultPanel from "./components/GenerateResultPanel"
|
|
import FrontendPreviewPlayer from "./components/FrontendPreviewPlayer"
|
|
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 { 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 formState = useGenerateFormState()
|
|
const {
|
|
currentStep,
|
|
setCurrentStep,
|
|
selectedTemplate,
|
|
setSelectedTemplate,
|
|
userTemplates,
|
|
selectedMaterials,
|
|
setSelectedMaterials,
|
|
materialMode,
|
|
setMaterialMode,
|
|
smartSelectedIds,
|
|
setSmartSelectedIds,
|
|
titleSettings,
|
|
setTitleSettings,
|
|
coverSettings,
|
|
setCoverSettings,
|
|
selectedVoice,
|
|
setSelectedVoice,
|
|
voiceMode,
|
|
setVoiceMode,
|
|
selectedClonedVoice,
|
|
setSelectedClonedVoice,
|
|
presetVoices,
|
|
cloneModalOpen,
|
|
setCloneModalOpen,
|
|
generateCount,
|
|
setGenerateCount,
|
|
videoRatio,
|
|
duration,
|
|
style,
|
|
autoSubtitles,
|
|
bgm,
|
|
editPlanId,
|
|
sourceEditPlanId,
|
|
previewVideo,
|
|
setPreviewVideo,
|
|
previewModalOpen,
|
|
setPreviewModalOpen,
|
|
previewTaskId,
|
|
setPreviewTaskId,
|
|
storedSourceEditPlanId,
|
|
setStoredSourceEditPlanId,
|
|
} = formState
|
|
|
|
/* ── 标题样式回调 ── */
|
|
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)
|
|
|
|
useEffect(() => {
|
|
// 如果 selectedVoice 是已上传的配音素材,直接用 file_url
|
|
const voiceAsset = voiceMaterials.find((m) => m.id === selectedVoice)
|
|
if (voiceAsset?.file_url) {
|
|
setPreviewVoiceAudioUrl(voiceAsset.file_url)
|
|
return
|
|
}
|
|
|
|
// 没有选中的 voice 或标题,跳过
|
|
const voiceId = selectedClonedVoice || selectedVoice
|
|
if (!voiceId || !titleSettings.title) {
|
|
setPreviewVoiceAudioUrl(null)
|
|
return
|
|
}
|
|
|
|
// 预设音色 / 克隆音色 → 调 TTS 合成
|
|
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()
|
|
|
|
const handleCloneSuccess = (voice: VoiceClone) => {
|
|
addClone(voice)
|
|
setCloneModalOpen(false)
|
|
message.success("音色克隆成功!")
|
|
}
|
|
|
|
/* ── 素材 ID 列表 ── */
|
|
const previewAssetIds = useMemo(
|
|
() => (materialMode === "auto" ? smartSelectedIds : selectedMaterials),
|
|
[materialMode, smartSelectedIds, selectedMaterials],
|
|
)
|
|
|
|
/* ── 当前模板对象 ── */
|
|
const currentTemplate = useMemo(
|
|
() => userTemplates.find((t) => t.id === selectedTemplate) || null,
|
|
[userTemplates, selectedTemplate],
|
|
)
|
|
|
|
/* ── BGM 配置 ── */
|
|
const bgmConfig = useMemo(
|
|
() => ({
|
|
enabled: bgm,
|
|
music_id: currentTemplate?.bgm_config?.music_id || "",
|
|
}),
|
|
[bgm, currentTemplate],
|
|
)
|
|
|
|
/* ── 加载素材详情(供前端预览播放器使用 + 配音时长校验) ── */
|
|
const previewAssetsEnabled = previewAssetIds.length > 0
|
|
const { assets: previewAssets } = usePreviewAssets(previewAssetIds, previewAssetsEnabled)
|
|
|
|
/* ── 视频总时长计算 ── */
|
|
const totalVideoDuration = useMemo(() => {
|
|
const exact = calculateTotalVideoDuration(previewAssets, currentTemplate ?? undefined)
|
|
if (exact > 0) return exact
|
|
return estimateTotalVideoDuration(currentTemplate ?? undefined)
|
|
}, [previewAssets, currentTemplate])
|
|
|
|
/* ── 步骤导航 ── */
|
|
const { goNext, goPrev } = useStepNavigation({
|
|
currentStep,
|
|
setCurrentStep,
|
|
selectedTemplate,
|
|
materialMode,
|
|
selectedMaterials,
|
|
smartSelectedIds,
|
|
titleSettings,
|
|
})
|
|
|
|
/* ── 视频生成核心逻辑 ── */
|
|
const {
|
|
generating,
|
|
progress,
|
|
generated,
|
|
generateError,
|
|
generatedVideos,
|
|
generate: handleGenerate,
|
|
retry: handleRetryGenerate,
|
|
dismissError: handleDismissError,
|
|
download: handleDownload,
|
|
share: handleShare,
|
|
} = useGenerateVideo({
|
|
titleSettings,
|
|
selectedTemplate,
|
|
selectedMaterials,
|
|
materialMode,
|
|
smartSelectedIds,
|
|
voiceMode,
|
|
selectedVoice,
|
|
selectedClonedVoice,
|
|
coverSettings,
|
|
videoRatio,
|
|
style,
|
|
duration,
|
|
autoSubtitles,
|
|
bgm,
|
|
generateCount,
|
|
sourceEditPlanId: storedSourceEditPlanId || sourceEditPlanId,
|
|
previewTaskId,
|
|
bgmConfig,
|
|
onGenerationSuccess: () => {
|
|
setPreviewTaskId(null)
|
|
setStoredSourceEditPlanId(null)
|
|
},
|
|
})
|
|
|
|
/* ================================================================
|
|
渲染
|
|
================================================================ */
|
|
|
|
return (
|
|
<div className="xx-generate-page">
|
|
<GenerateHeader fromEditPlan={!!editPlanId} />
|
|
|
|
<GenerateStepsBar currentStep={currentStep} onStepClick={setCurrentStep} />
|
|
|
|
<div className="xx-generate-layout">
|
|
{/* ════ 左侧:表单区 ════ */}
|
|
<div className="xx-generate-form">
|
|
<GenerateStepContent
|
|
currentStep={currentStep}
|
|
userTemplates={userTemplates}
|
|
selectedTemplate={selectedTemplate}
|
|
onSelectTemplate={setSelectedTemplate}
|
|
materialMode={materialMode}
|
|
onMaterialModeChange={setMaterialMode}
|
|
selectedMaterials={selectedMaterials}
|
|
onSelectedMaterialsChange={setSelectedMaterials}
|
|
smartSelectedIds={smartSelectedIds}
|
|
onSmartSelectedIdsChange={setSmartSelectedIds}
|
|
titleSettings={titleSettings}
|
|
onTitleSettingsChange={setTitleSettings}
|
|
onUpdatePosition={styleUpdaters.updatePosition}
|
|
onUpdateFont={styleUpdaters.updateFont}
|
|
onUpdateSize={styleUpdaters.updateSize}
|
|
onToggleBold={styleUpdaters.toggleBold}
|
|
onToggleItalic={styleUpdaters.toggleItalic}
|
|
onToggleStroke={styleUpdaters.toggleStroke}
|
|
onToggleShadow={styleUpdaters.toggleShadow}
|
|
onApplyPreset={styleUpdaters.applyPreset}
|
|
activePreset={styleUpdaters.activePreset}
|
|
titlePresets={styleUpdaters.titlePresets}
|
|
onPreviewTaskCreated={setPreviewTaskId}
|
|
onSourceEditPlanIdExtracted={setStoredSourceEditPlanId}
|
|
bgm={bgm}
|
|
bgmConfig={bgmConfig}
|
|
coverSettings={coverSettings}
|
|
onCoverSettingsChange={setCoverSettings}
|
|
duration={duration}
|
|
selectedVoice={selectedVoice}
|
|
onSelectedVoiceChange={setSelectedVoice}
|
|
totalVideoDuration={totalVideoDuration}
|
|
voiceMode={voiceMode}
|
|
onVoiceModeChange={setVoiceMode}
|
|
selectedClonedVoice={selectedClonedVoice}
|
|
onSelectedClonedVoiceChange={setSelectedClonedVoice}
|
|
clonedVoices={clonedVoices}
|
|
addClone={addClone}
|
|
hasProcessing={hasProcessing}
|
|
cloneModalOpen={cloneModalOpen}
|
|
onCloneModalOpenChange={setCloneModalOpen}
|
|
generateCount={generateCount}
|
|
onGenerateCountChange={setGenerateCount}
|
|
generating={generating}
|
|
generated={generated}
|
|
generateError={generateError}
|
|
progress={progress}
|
|
generatedVideos={generatedVideos}
|
|
onRetry={handleRetryGenerate}
|
|
onDismissError={handleDismissError}
|
|
presetVoices={presetVoices}
|
|
/>
|
|
|
|
<GenerateStepActions
|
|
currentStep={currentStep}
|
|
onPrev={goPrev}
|
|
onNext={goNext}
|
|
onGenerate={handleGenerate}
|
|
generating={generating}
|
|
generated={generated}
|
|
generateError={generateError}
|
|
/>
|
|
</div>
|
|
|
|
{/* ════ 右侧:预览 + 结果 ════ */}
|
|
<div className="xx-generate-right-col">
|
|
{currentStep >= 4 && !!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 >= 6 && (
|
|
<GenerateResultPanel
|
|
generated={generated}
|
|
generating={generating}
|
|
progress={progress}
|
|
generateError={generateError}
|
|
generatedVideos={generatedVideos}
|
|
onVideoPreview={(video) => {
|
|
setPreviewVideo(video)
|
|
setPreviewModalOpen(true)
|
|
}}
|
|
onDownload={handleDownload}
|
|
onShare={handleShare}
|
|
onGoToLibrary={() => navigate("/app/products")}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* 视频预览弹窗 */}
|
|
<Modal
|
|
className="xx-preview-modal"
|
|
open={previewModalOpen}
|
|
onCancel={() => setPreviewModalOpen(false)}
|
|
footer={null}
|
|
width="80vw"
|
|
centered
|
|
destroyOnClose
|
|
>
|
|
{previewVideo && (
|
|
<div className="xx-preview-modal-content">
|
|
<video
|
|
src={previewVideo.download_url || previewVideo.file_url}
|
|
controls
|
|
autoPlay
|
|
style={{ width: "100%", maxHeight: "70vh", objectFit: "contain" }}
|
|
poster={previewVideo.thumbnail_url || undefined}
|
|
/>
|
|
</div>
|
|
)}
|
|
</Modal>
|
|
|
|
{/* 音色克隆弹窗 */}
|
|
<CloneModal
|
|
open={cloneModalOpen}
|
|
onClose={() => setCloneModalOpen(false)}
|
|
onSuccess={handleCloneSuccess}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default GeneratePage
|