feat: #1281 支持生成多个预览视频 + 配音前置 #1285

Merged
auto-approve-bot merged 4 commits from feat/1281-multi-preview-with-voice into develop 2026-08-07 23:01:06 +08:00
12 changed files with 834 additions and 521 deletions
+49 -25
View File
@@ -1,6 +1,6 @@
/**
* 智能剪辑页面 — V21 原型 1:1 还原
* 7 步向导:选择模板 → 选择素材 → 生成预览 → 选择标题 → 选择配音 → 选择封面 → 确认生成
* 智能剪辑页面 — V22 多预览 + 配音前置
* 7 步向导:选择模板 → 选择素材 → 选择配音 → 生成预览 → 选择标题 → 选择封面 → 确认生成
* 左右布局:左侧 generate-form + 右侧 generate-preview
* 主组件仅保留整体布局与事件编排
* 状态管理 → hooks/useGenerateFormState
@@ -9,7 +9,7 @@
* 底部按钮 → components/GenerateStepActions
* 生成核心逻辑 → hooks/useGenerateVideo
*/
import React from "react"
import React, { useState, useMemo } from "react"
import { Modal, message } from "antd"
import { useNavigate } from "react-router-dom"
import type { VoiceClone } from "@/api/voice-clone"
@@ -24,7 +24,7 @@ import GenerateStepActions from "./components/GenerateStepActions"
import { useGenerateFormState } from "./hooks/useGenerateFormState"
import { useStepNavigation } from "./hooks/useStepNavigation"
import { useGenerateVideo } from "./hooks/useGenerateVideo"
import { useStep3Preview } from "./hooks/useStep3Preview"
import { useStep4Preview } from "./hooks/useStep4Preview"
import "./generate.css"
const GeneratePage: React.FC = () => {
@@ -80,8 +80,24 @@ const GeneratePage: React.FC = () => {
message.success("音色克隆成功!")
}
/* ── Step3 预览生成 ── */
const step3Preview = useStep3Preview({
/* ── 预览数量(多预览) ── */
const [previewCount, setPreviewCount] = useState(1)
/* ── 根据 voiceMode 构建 voiceIds 传给预览接口 ── */
/* selectedVoice / selectedClonedVoice 均为 string 类型(voice ID),
见 useGenerateFormState 返回值类型定义 */
const previewVoiceIds = useMemo((): string[] => {
if (voiceMode === "clone") {
const id: string = selectedClonedVoice
return id ? [id] : []
}
// preset / custom 模式
const id: string = selectedVoice
return id ? [id] : []
}, [voiceMode, selectedVoice, selectedClonedVoice])
/* ── Step4 预览生成(多预览 + voice_ids ── */
const step4Preview = useStep4Preview({
templates: userTemplates,
selectedTemplate,
materialMode,
@@ -89,6 +105,8 @@ const GeneratePage: React.FC = () => {
smartSelectedIds,
duration,
videoRatio,
voiceIds: previewVoiceIds,
previewCount,
})
/* ── 步骤导航 ── */
@@ -100,7 +118,7 @@ const GeneratePage: React.FC = () => {
selectedMaterials,
smartSelectedIds,
titleSettings,
previewReady: step3Preview.canProceed,
previewReady: step4Preview.canProceed,
})
/* ── 视频生成核心逻辑 ── */
@@ -187,14 +205,20 @@ const GeneratePage: React.FC = () => {
onDismissError={handleDismissError}
presetVoices={presetVoices}
videoRatio={videoRatio}
previewStatus={step3Preview.previewStatus}
previewResult={step3Preview.previewResult}
previewError={step3Preview.previewError}
previewTemplateName={step3Preview.templateName}
previewMaterialCount={step3Preview.materialCount}
previewProgress={step3Preview.progress}
onGeneratePreview={step3Preview.generatePreview}
onRegeneratePreview={step3Preview.regeneratePreview}
/* Step4 多预览 */
previewCount={previewCount}
onPreviewCountChange={setPreviewCount}
previewItems={step4Preview.items}
previewSelectedIndex={step4Preview.selectedIndex}
onSelectPreview={step4Preview.setSelectedIndex}
previewOverallStatus={step4Preview.previewStatus}
previewOverallError={step4Preview.previewError}
previewOverallProgress={step4Preview.progress}
previewAnyGenerating={step4Preview.anyGenerating}
previewTemplateName={step4Preview.templateName}
previewMaterialCount={step4Preview.materialCount}
onGeneratePreview={step4Preview.generatePreview}
onRegeneratePreview={step4Preview.regeneratePreview}
/>
<GenerateStepActions
@@ -210,22 +234,22 @@ const GeneratePage: React.FC = () => {
{/* ════ 右侧:预览 + 生成结果 ════ */}
<div className="xx-generate-right-col">
{/* 预览视频面板(Step3+ 常驻) */}
{currentStep >= 3 && (
{/* 预览视频面板(Step4+ 常驻,展示选中的预览 */}
{currentStep >= 4 && (
<PreviewVideoPanel
previewStatus={step3Preview.previewStatus}
previewResult={step3Preview.previewResult}
previewError={step3Preview.previewError}
progress={step3Preview.progress}
previewStatus={step4Preview.previewStatus}
previewResult={step4Preview.previewResult}
previewError={step4Preview.previewError}
progress={step4Preview.progress}
videoRatio={videoRatio}
onRegenerate={step3Preview.regeneratePreview}
onRegenerate={step4Preview.regeneratePreview}
titleText={titleSettings.title}
titleSettings={titleSettings}
titleSettings={currentStep >= 5 ? titleSettings : undefined}
/>
)}
{/* 正式生成结果(Step4+ 才显示) */}
{currentStep >= 4 && (
{/* 正式生成结果(Step5+ 才显示) */}
{currentStep >= 5 && (
<GenerateResultPanel
generated={generated}
generating={generating}
@@ -1,6 +1,7 @@
/**
* GeneratePage 步骤内容渲染
* 根据当前步骤渲染对应的 Step 组件
* 步骤顺序:模板(1) → 素材(2) → 配音(3) → 预览(4) → 标题(5) → 封面(6) → 确认(7)
*/
import React from "react"
import type { EditingTemplate } from "@/api/editing-planner"
@@ -8,12 +9,12 @@ import type { PresetVoiceItem } from "@/api/voices"
import type { VoiceClone } from "@/api/voice-clone"
import type { CoverConfig } from "../../editing-planner/types"
import type { TitleSettings } from "../types"
import type { PreviewResult, PreviewStatus } from "../hooks/useStep3Preview"
import type { PreviewItem, PreviewStatus } from "../hooks/useStep4Preview"
import Step1TemplateSelect from "../components/Step1TemplateSelect"
import Step2MaterialSelect from "../components/Step2MaterialSelect"
import Step3GeneratePreview from "../components/Step3GeneratePreview"
import Step4TitleSettings from "../components/Step4TitleSettings"
import Step5VoiceSelect from "../components/Step5VoiceSelect"
import Step3VoiceSelect from "../components/Step5VoiceSelect"
import Step4GeneratePreview from "../components/Step4GeneratePreview"
import Step5TitleSettings from "../components/Step4TitleSettings"
import Step6CoverSettings from "../components/Step6CoverSettings"
import Step7ConfirmGenerate from "../components/Step7ConfirmGenerate"
import type { GeneratedVideo } from "@/api/template-editor"
@@ -63,13 +64,18 @@ export interface GenerateStepContentProps {
/* 其他 */
presetVoices: PresetVoiceItem[]
videoRatio: string
/* Step3 预览 */
previewStatus: PreviewStatus
previewResult: PreviewResult | null
previewError: string
/* Step4 预览(多预览) */
previewCount: number
onPreviewCountChange: (count: number) => void
previewItems: PreviewItem[]
previewSelectedIndex: number
onSelectPreview: (index: number) => void
previewOverallStatus: PreviewStatus
previewOverallError: string
previewOverallProgress: number
previewAnyGenerating: boolean
previewTemplateName: string
previewMaterialCount: string
previewProgress: number
onGeneratePreview: () => void
onRegeneratePreview: () => void
}
@@ -107,12 +113,17 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
onDismissError,
presetVoices,
videoRatio,
previewStatus,
previewResult,
previewError,
previewCount,
onPreviewCountChange,
previewItems,
previewSelectedIndex,
onSelectPreview,
previewOverallStatus,
previewOverallError,
previewOverallProgress,
previewAnyGenerating,
previewTemplateName,
previewMaterialCount,
previewProgress,
onGeneratePreview,
onRegeneratePreview,
} = props
@@ -139,31 +150,36 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
)
case 3:
return (
<Step3GeneratePreview
templateName={previewTemplateName}
materialCount={previewMaterialCount}
duration={duration}
videoRatio={videoRatio}
previewStatus={previewStatus}
previewResult={previewResult}
previewError={previewError}
progress={previewProgress}
onGeneratePreview={onGeneratePreview}
onRegeneratePreview={onRegeneratePreview}
<Step3VoiceSelect
selectedVoice={selectedVoice}
onSelectedVoiceChange={onSelectedVoiceChange}
/>
)
case 4:
return (
<Step4TitleSettings
titleSettings={titleSettings}
onTitleSettingsChange={onTitleSettingsChange}
<Step4GeneratePreview
templateName={previewTemplateName}
materialCount={previewMaterialCount}
duration={duration}
videoRatio={videoRatio}
previewCount={previewCount}
onPreviewCountChange={onPreviewCountChange}
items={previewItems}
selectedIndex={previewSelectedIndex}
onSelectPreview={onSelectPreview}
overallStatus={previewOverallStatus}
overallError={previewOverallError}
overallProgress={previewOverallProgress}
anyGenerating={previewAnyGenerating}
onGeneratePreview={onGeneratePreview}
onRegeneratePreview={onRegeneratePreview}
/>
)
case 5:
return (
<Step5VoiceSelect
selectedVoice={selectedVoice}
onSelectedVoiceChange={onSelectedVoiceChange}
<Step5TitleSettings
titleSettings={titleSettings}
onTitleSettingsChange={onTitleSettingsChange}
/>
)
case 6:
@@ -1,7 +1,7 @@
/**
* 右侧预览视频面板
* Step3 生成预览后常驻显示预览视频
* Step4+ 用 Canvas 绘制标题预览(替代 CSS overlay,与 ASS 渲染行为一致)
* Step4 生成预览后常驻显示预览视频
* Step5+ 用 Canvas 绘制标题预览(替代 CSS overlay,与 ASS 渲染行为一致)
*
* 设计说明:标题预览仅在有视频时显示(叠加在视频画面上方)。
* 无视频状态(idle/loading/error)下不再单独显示标题预览,这是有意为之的设计简化。
@@ -13,7 +13,7 @@
*/
import React, { useRef, useEffect, useCallback } from "react"
import { PlayCircleOutlined, LoadingOutlined } from "@ant-design/icons"
import type { PreviewResult, PreviewStatus } from "../hooks/useStep3Preview"
import type { PreviewResult, PreviewStatus } from "../hooks/useStep4Preview"
import type { TitleSettings } from "../types"
interface PreviewVideoPanelProps {
@@ -23,9 +23,9 @@ interface PreviewVideoPanelProps {
progress: number
videoRatio: string
onRegenerate: () => void
/** 标题文字(Step4 起传入) */
/** 标题文字(Step5 起传入) */
titleText?: string
/** 标题样式设置(Step4 起传入) */
/** 标题样式设置(Step5 起传入) */
titleSettings?: TitleSettings
}
@@ -1,128 +0,0 @@
/**
* Step 3 生成预览组件
* 调用后端预览生成接口,展示真实视频预览
*/
import React from "react"
import {
CheckCircleFilled,
LoadingOutlined,
ReloadOutlined,
PlayCircleOutlined,
ExclamationCircleFilled,
ClockCircleOutlined,
} from "@ant-design/icons"
import type { PreviewResult, PreviewStatus } from "../hooks/useStep3Preview"
interface Step3GeneratePreviewProps {
templateName: string
materialCount: string
duration: number
videoRatio: string
previewStatus: PreviewStatus
previewResult: PreviewResult | null
previewError: string
progress: number
onGeneratePreview: () => void
onRegeneratePreview: () => void
}
const Step3GeneratePreview: React.FC<Step3GeneratePreviewProps> = ({
templateName: _templateName,
materialCount: _materialCount,
videoRatio: _videoRatio,
previewStatus,
previewResult,
previewError,
progress,
onGeneratePreview,
onRegeneratePreview,
}) => {
return (
<div className="xx-form-section">
<h3>🎬 </h3>
{/* 预览生成按钮 */}
{previewStatus === "idle" && (
<div className="xx-preview-generate-section">
<div className="xx-preview-generate-hint">
<PlayCircleOutlined style={{ fontSize: 32, color: "#3b82f6", marginBottom: 12 }} />
<p className="xx-preview-generate-title"></p>
<p className="xx-preview-generate-desc">
AI 480p
</p>
</div>
<button
className="xx-btn xx-btn-primary xx-preview-generate-btn"
onClick={onGeneratePreview}
>
</button>
</div>
)}
{/* 排队中 */}
{previewStatus === "pending" && (
<div className="xx-preview-loading">
<ClockCircleOutlined style={{ fontSize: 32, color: "#faad14" }} spin />
<p className="xx-preview-loading-text">...</p>
<p className="xx-preview-loading-desc"></p>
</div>
)}
{/* 生成中 */}
{previewStatus === "generating" && (
<div className="xx-preview-loading">
<LoadingOutlined style={{ fontSize: 32, color: "#3b82f6" }} spin />
<p className="xx-preview-loading-text">... {progress}%</p>
<p className="xx-preview-loading-desc">AI </p>
<div className="xx-preview-progress-bar">
<div className="xx-preview-progress-fill" style={{ width: `${progress}%` }} />
</div>
</div>
)}
{/* 生成失败 */}
{previewStatus === "error" && (
<div className="xx-preview-error">
<ExclamationCircleFilled style={{ fontSize: 28, color: "#ef4444" }} />
<p className="xx-preview-error-text"></p>
<p className="xx-preview-error-desc">
{typeof previewError === "string" && previewError ? previewError : "请稍后重试"}
</p>
<button className="xx-btn xx-btn-primary" onClick={onRegeneratePreview}>
<ReloadOutlined />
</button>
</div>
)}
{/* 生成成功 - 视频预览 */}
{previewStatus === "ready" && previewResult && (
<>
<div className="xx-preview-tip">
<CheckCircleFilled style={{ color: "#52c41a", marginRight: 8 }} />
<span></span>
<button
className="xx-preview-regenerate-btn"
onClick={onRegeneratePreview}
title="重新生成"
>
<ReloadOutlined />
</button>
</div>
{/* 视频播放器 */}
<div className="xx-preview-video-wrapper">
<video
className="xx-preview-video"
src={previewResult.videoUrl}
controls
preload="metadata"
/>
</div>
</>
)}
</div>
)
}
export default Step3GeneratePreview
@@ -0,0 +1,278 @@
/**
* Step 4 生成预览组件(支持多预览)
* 调用后端预览生成接口,展示多个真实视频预览(网格布局)
*/
import React from "react"
import {
CheckCircleFilled,
LoadingOutlined,
ReloadOutlined,
PlayCircleOutlined,
ExclamationCircleFilled,
ClockCircleOutlined,
} from "@ant-design/icons"
import type { PreviewItem, PreviewStatus } from "../hooks/useStep4Preview"
interface Step4GeneratePreviewProps {
templateName: string
materialCount: string
duration: number
videoRatio: string
previewCount: number
onPreviewCountChange: (count: number) => void
items: PreviewItem[]
selectedIndex: number
onSelectPreview: (index: number) => void
overallStatus: PreviewStatus
overallError: string
overallProgress: number
anyGenerating: boolean
onGeneratePreview: () => void
onRegeneratePreview: () => void
}
/** 预览数量选项 */
const PREVIEW_COUNT_OPTIONS = [
{ value: 1, label: "1个" },
{ value: 2, label: "2个" },
{ value: 3, label: "3个" },
]
const Step4GeneratePreview: React.FC<Step4GeneratePreviewProps> = ({
templateName: _templateName,
materialCount: _materialCount,
videoRatio: _videoRatio,
previewCount,
onPreviewCountChange,
items,
selectedIndex,
onSelectPreview,
overallStatus,
overallError,
overallProgress,
anyGenerating,
onGeneratePreview,
onRegeneratePreview,
}) => {
const isIdle = overallStatus === "idle"
const isError = overallStatus === "error" && !items.some((it) => it.status === "ready")
return (
<div className="xx-form-section">
<h3>🎬 </h3>
{/* 预览数量选择器(仅在 idle 状态显示) */}
{isIdle && (
<div style={{ marginBottom: 16, display: "flex", alignItems: "center", gap: 12 }}>
<span style={{ fontSize: 14, color: "#666" }}></span>
<div style={{ display: "flex", gap: 8 }}>
{PREVIEW_COUNT_OPTIONS.map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => onPreviewCountChange(opt.value)}
style={{
padding: "4px 12px",
borderRadius: 6,
border: previewCount === opt.value ? "1px solid #1677ff" : "1px solid #d9d9d9",
background: previewCount === opt.value ? "#e6f4ff" : "#fff",
color: previewCount === opt.value ? "#1677ff" : "#666",
cursor: "pointer",
fontSize: 13,
fontWeight: previewCount === opt.value ? 600 : 400,
}}
>
{opt.label}
</button>
))}
</div>
{previewCount > 1 && (
<span style={{ fontSize: 12, color: "#999" }}></span>
)}
</div>
)}
{/* 预览生成按钮(idle 状态) */}
{isIdle && (
<div className="xx-preview-generate-section">
<div className="xx-preview-generate-hint">
<PlayCircleOutlined style={{ fontSize: 32, color: "#3b82f6", marginBottom: 12 }} />
<p className="xx-preview-generate-title"></p>
<p className="xx-preview-generate-desc">
AI
{previewCount > 1 ? `${previewCount}个不同版本的` : ""}480p
</p>
</div>
<button
className="xx-btn xx-btn-primary xx-preview-generate-btn"
onClick={onGeneratePreview}
>
{previewCount > 1 ? `${previewCount}个)` : ""}
</button>
</div>
)}
{/* 整体排队中(所有都在 pending) */}
{anyGenerating && items.every((it) => it.status === "pending") && (
<div className="xx-preview-loading">
<ClockCircleOutlined style={{ fontSize: 32, color: "#faad14" }} spin />
<p className="xx-preview-loading-text">...</p>
<p className="xx-preview-loading-desc"></p>
</div>
)}
{/* 多预览网格(生成中/完成/部分完成) */}
{(anyGenerating || overallStatus === "ready") && items.length > 0 && (
<div
style={{
display: "grid",
gridTemplateColumns: `repeat(${Math.min(items.length, 3)}, 1fr)`,
gap: 12,
marginBottom: 16,
}}
>
{items.map((item) => {
const isSelected = item.index === selectedIndex
return (
<div
key={item.index}
onClick={() => {
if (item.status === "ready") onSelectPreview(item.index)
}}
style={{
borderRadius: 8,
border: isSelected ? "2px solid #1677ff" : "1px solid #e8e8e8",
overflow: "hidden",
cursor: item.status === "ready" ? "pointer" : "default",
opacity: item.status === "error" ? 0.6 : 1,
transition: "all 0.2s",
}}
>
{/* 缩略图/状态区域 */}
<div
style={{
aspectRatio: "9/16",
maxHeight: 180,
background: "#000",
display: "flex",
alignItems: "center",
justifyContent: "center",
position: "relative",
}}
>
{item.status === "ready" && item.result && (
<video
src={item.result.videoUrl}
preload="metadata"
style={{
width: "100%",
height: "100%",
objectFit: "contain",
}}
/>
)}
{(item.status === "pending" || item.status === "generating") && (
<div style={{ textAlign: "center" }}>
<LoadingOutlined style={{ fontSize: 24, color: "#fff" }} spin />
<p style={{ color: "rgba(255,255,255,0.8)", fontSize: 12, marginTop: 8 }}>
{item.status === "pending" ? "排队中..." : `生成中 ${item.progress}%`}
</p>
</div>
)}
{item.status === "error" && (
<div style={{ textAlign: "center", padding: 8 }}>
<ExclamationCircleFilled style={{ fontSize: 20, color: "#ef4444" }} />
<p
style={{
color: "rgba(255,255,255,0.7)",
fontSize: 11,
marginTop: 4,
}}
>
</p>
</div>
)}
{/* 选中角标 */}
{isSelected && item.status === "ready" && (
<div
style={{
position: "absolute",
top: 4,
right: 4,
background: "#1677ff",
color: "#fff",
fontSize: 10,
padding: "2px 6px",
borderRadius: 4,
}}
>
#{item.index + 1}
</div>
)}
</div>
{/* 底部信息 */}
{item.status === "ready" && item.result && (
<div
style={{
padding: "6px 8px",
background: "#fafafa",
fontSize: 11,
color: "#666",
display: "flex",
justifyContent: "space-between",
}}
>
<span>{item.result.duration.toFixed(1)}</span>
<span>{item.result.clipCount}</span>
</div>
)}
</div>
)
})}
</div>
)}
{/* 整体进度条(多预览生成中) */}
{anyGenerating && (
<div className="xx-preview-progress-bar" style={{ marginBottom: 12 }}>
<div className="xx-preview-progress-fill" style={{ width: `${overallProgress}%` }} />
</div>
)}
{/* 全部完成提示 */}
{overallStatus === "ready" && (
<div className="xx-preview-tip">
<CheckCircleFilled style={{ color: "#52c41a", marginRight: 8 }} />
<span>
{items.filter((it) => it.status === "ready").length}
{items.length > 1 ? ",点击选择要查看的版本" : ",确认效果后进入下一步"}
</span>
<button
className="xx-preview-regenerate-btn"
onClick={onRegeneratePreview}
title="重新生成"
>
<ReloadOutlined />
</button>
</div>
)}
{/* 全部失败 */}
{isError && (
<div className="xx-preview-error">
<ExclamationCircleFilled style={{ fontSize: 28, color: "#ef4444" }} />
<p className="xx-preview-error-text"></p>
<p className="xx-preview-error-desc">
{typeof overallError === "string" && overallError ? overallError : "请稍后重试"}
</p>
<button className="xx-btn xx-btn-primary" onClick={onRegeneratePreview}>
<ReloadOutlined />
</button>
</div>
)}
</div>
)
}
export default Step4GeneratePreview
+3 -3
View File
@@ -31,9 +31,9 @@ export const VOICE_GENDER_ICON: Record<string, string> = {
export const STEPS = [
{ key: 1, label: "选择模板" },
{ key: 2, label: "选择素材" },
{ key: 3, label: "生成预览" },
{ key: 4, label: "选择标题" },
{ key: 5, label: "选择配音" },
{ key: 3, label: "选择配音" },
{ key: 4, label: "生成预览" },
{ key: 5, label: "选择标题" },
{ key: 6, label: "选择封面" },
{ key: 7, label: "确认生成" },
]
@@ -1,308 +0,0 @@
/**
* Step 3 生成预览 Hook
* 调用 /generation/preview 接口创建预览任务,轮询状态直到完成
*/
import { useState, useCallback, useMemo, useEffect, useRef } from "react"
import { createPreview, getPreviewStatus } from "@/api/generation"
import type { PreviewTaskResponse, PreviewStatus as ApiPreviewStatus } from "@/api/generation"
import type { EditingTemplate } from "@/api/editing-planner"
import { safeExtractError } from "./generate-video/errorUtils"
/** 安全地将值转为字符串,防止对象被直接渲染导致 React Error #31 */
const safeString = (val: unknown, fallback: string): string => {
if (val == null) return fallback
const s = safeExtractError(val)
return s || fallback
}
/** 安全地将值转为数字,防止非数字值进入渲染 */
const safeNumber = (val: unknown, fallback = 0): number => {
if (typeof val === "number" && !Number.isNaN(val)) return val
if (typeof val === "string") {
const n = Number(val)
return Number.isNaN(n) ? fallback : n
}
return fallback
}
interface UseStep3PreviewProps {
templates: EditingTemplate[]
selectedTemplate: string
materialMode: "manual" | "auto"
selectedMaterials: string[]
smartSelectedIds: string[]
duration: number
videoRatio: string
titleText?: string
voiceId?: string
}
export type PreviewStatus = "idle" | "pending" | "generating" | "ready" | "error"
/** 预览生成结果 */
export interface PreviewResult {
taskId: string
videoUrl: string
clipCount: number
transitionCount: number
materialUsage: number
duration: number
fileSize: number
generateDuration: number
progress: number
}
// 轮询超时时间(10 分钟)
const POLL_TIMEOUT_MS = 10 * 60 * 1000
export function useStep3Preview({
templates,
selectedTemplate,
materialMode,
selectedMaterials,
smartSelectedIds,
duration,
videoRatio,
}: UseStep3PreviewProps) {
const templateName = useMemo(
() => templates.find((t) => t.id === selectedTemplate)?.name ?? "未选择",
[templates, selectedTemplate],
)
const materialCount = useMemo(() => {
if (materialMode === "auto") {
return `${smartSelectedIds.length} 个素材(智能匹配)`
}
return `${selectedMaterials.length} 个素材`
}, [materialMode, selectedMaterials.length, smartSelectedIds.length])
const materialTotal = materialMode === "auto" ? smartSelectedIds.length : selectedMaterials.length
/* ── 预览生成状态 ── */
const [previewStatus, setPreviewStatus] = useState<PreviewStatus>("idle")
const [previewResult, setPreviewResult] = useState<PreviewResult | null>(null)
const [previewError, setPreviewError] = useState<string>("")
const [progress, setProgress] = useState(0)
// 任务 ID + 轮询定时器,用于防止竞态条件
const currentTaskIdRef = useRef<string | null>(null)
const pollTimerRef = useRef<ReturnType<typeof setTimeout>>()
const startTimeRef = useRef<number>(0)
const clearPollTimer = useCallback(() => {
if (pollTimerRef.current) {
clearTimeout(pollTimerRef.current)
pollTimerRef.current = undefined
}
}, [])
/* ── 参数变化时重置预览状态 ── */
const prevDepsRef = useRef({
selectedTemplate,
materialMode,
selectedMaterials: [...selectedMaterials].sort().join(","),
smartSelectedIds: [...smartSelectedIds].sort().join(","),
duration,
videoRatio,
})
useEffect(() => {
const currentKey = [
selectedTemplate,
materialMode,
[...selectedMaterials].sort().join(","),
[...smartSelectedIds].sort().join(","),
duration,
videoRatio,
].join("|")
const prevKey = [
prevDepsRef.current.selectedTemplate,
prevDepsRef.current.materialMode,
prevDepsRef.current.selectedMaterials,
prevDepsRef.current.smartSelectedIds,
prevDepsRef.current.duration,
prevDepsRef.current.videoRatio,
].join("|")
if (prevKey !== currentKey && previewStatus !== "idle") {
// 参数变化,作废当前任务
currentTaskIdRef.current = null
clearPollTimer()
setPreviewStatus("idle")
setPreviewResult(null)
setPreviewError("")
setProgress(0)
}
prevDepsRef.current = {
selectedTemplate,
materialMode,
selectedMaterials: [...selectedMaterials].sort().join(","),
smartSelectedIds: [...smartSelectedIds].sort().join(","),
duration,
videoRatio,
}
}, [
selectedTemplate,
materialMode,
selectedMaterials,
smartSelectedIds,
duration,
videoRatio,
previewStatus,
clearPollTimer,
])
// 组件卸载时清理轮询
useEffect(() => {
return () => {
clearPollTimer()
}
}, [clearPollTimer])
/** 轮询预览任务状态 */
const pollPreviewStatus = useCallback((taskId: string) => {
const poll = async () => {
// 检查是否已被取消(参数变化或重新生成)
if (currentTaskIdRef.current !== taskId) return
// 超时检查
if (Date.now() - startTimeRef.current > POLL_TIMEOUT_MS) {
setPreviewError("预览生成超时,请重试")
setPreviewStatus("error")
return
}
try {
const data: PreviewTaskResponse = await getPreviewStatus(taskId)
if (currentTaskIdRef.current !== taskId) return
const status = data.status as ApiPreviewStatus
if (status === "completed") {
setProgress(100)
setPreviewResult({
taskId: safeString(data.task_id, ""),
videoUrl: safeString(data.video_url, ""),
clipCount: safeNumber(data.clip_count),
transitionCount: safeNumber(data.transition_count),
materialUsage: safeNumber(data.material_usage),
duration: safeNumber(data.duration),
fileSize: safeNumber(data.file_size),
generateDuration: safeNumber(data.generate_duration),
progress: 100,
})
setPreviewStatus("ready")
return
}
if (status === "failed") {
setPreviewError(safeString(data.error_message, "预览生成失败,请重试"))
setPreviewStatus("error")
return
}
if (status === "cancelled") {
setPreviewError("预览任务已取消")
setPreviewStatus("error")
return
}
// pending / generating 状态继续轮询
setProgress(safeNumber(data.progress))
if (status === "pending") {
setPreviewStatus("pending")
pollTimerRef.current = setTimeout(poll, 5000)
} else {
setPreviewStatus("generating")
pollTimerRef.current = setTimeout(poll, 2000)
}
} catch (pollErr) {
if (currentTaskIdRef.current !== taskId) return
// 轮询出错,延迟后重试
pollTimerRef.current = setTimeout(poll, 3000)
}
}
// 首次延迟 1 秒开始轮询
pollTimerRef.current = setTimeout(poll, 1000)
}, [])
/** 生成预览 */
const generatePreview = useCallback(async () => {
if (!selectedTemplate) {
setPreviewError("请先选择模板")
setPreviewStatus("error")
return
}
if (materialTotal === 0) {
setPreviewError("请先选择素材")
setPreviewStatus("error")
return
}
// 取消之前的轮询
clearPollTimer()
setPreviewStatus("pending")
setPreviewError("")
setProgress(0)
setPreviewResult(null)
startTimeRef.current = Date.now()
try {
const assetIds = materialMode === "auto" ? smartSelectedIds : selectedMaterials
const response = await createPreview({
template_id: selectedTemplate,
asset_ids: assetIds,
duration: duration || undefined,
video_ratio: videoRatio,
})
// 竞态检查
if (startTimeRef.current === 0) return // 已被重置
currentTaskIdRef.current = response.task_id
pollPreviewStatus(response.task_id)
} catch (e) {
setPreviewError(safeString(e instanceof Error ? e.message : e, "预览生成失败"))
setPreviewStatus("error")
}
}, [
selectedTemplate,
materialTotal,
materialMode,
smartSelectedIds,
selectedMaterials,
duration,
videoRatio,
clearPollTimer,
pollPreviewStatus,
])
/** 重新生成预览 */
const regeneratePreview = useCallback(() => {
generatePreview()
}, [generatePreview])
/** 是否可以下一步(预览已生成) */
const canProceed = previewStatus === "ready"
return {
templateName,
materialCount,
duration,
videoRatio,
// 预览生成状态
previewStatus,
previewResult,
previewError,
progress,
canProceed,
generatePreview,
regeneratePreview,
}
}
export default useStep3Preview
@@ -0,0 +1,429 @@
/**
* Step 4 生成预览 Hook(支持多预览 + voice_ids
* 调用 /generation/preview 接口创建多个预览任务,轮询状态直到全部完成
*/
import { useState, useCallback, useMemo, useEffect, useRef } from "react"
import { createPreview, getPreviewStatus } from "@/api/generation"
import type { PreviewTaskResponse, PreviewStatus as ApiPreviewStatus } from "@/api/generation"
import type { EditingTemplate } from "@/api/editing-planner"
import { safeExtractError } from "./generate-video/errorUtils"
/** 安全地将值转为字符串,防止对象被直接渲染导致 React Error #31 */
const safeString = (val: unknown, fallback: string): string => {
if (val == null) return fallback
const s = safeExtractError(val)
return s || fallback
}
/** 安全地将值转为数字,防止非数字值进入渲染 */
const safeNumber = (val: unknown, fallback = 0): number => {
if (typeof val === "number" && !Number.isNaN(val)) return val
if (typeof val === "string") {
const n = Number(val)
return Number.isNaN(n) ? fallback : n
}
return fallback
}
interface UseStep4PreviewProps {
templates: EditingTemplate[]
selectedTemplate: string
materialMode: "manual" | "auto"
selectedMaterials: string[]
smartSelectedIds: string[]
duration: number
videoRatio: string
/** 配音 voice_ids(传给后端,让预览包含配音音频) */
voiceIds?: string[]
/** 要生成的预览数量 */
previewCount?: number
}
export type PreviewStatus = "idle" | "pending" | "generating" | "ready" | "error"
/** 单个预览生成结果 */
export interface PreviewResult {
taskId: string
videoUrl: string
clipCount: number
transitionCount: number
materialUsage: number
duration: number
fileSize: number
generateDuration: number
progress: number
}
/** 单个预览项的完整状态(用于多预览) */
export interface PreviewItem {
index: number
status: PreviewStatus
result: PreviewResult | null
error: string
progress: number
}
// 轮询超时时间(10 分钟)
const POLL_TIMEOUT_MS = 10 * 60 * 1000
/** 初始单项状态 */
const createInitialItem = (index: number): PreviewItem => ({
index,
status: "idle",
result: null,
error: "",
progress: 0,
})
export function useStep4Preview({
templates,
selectedTemplate,
materialMode,
selectedMaterials,
smartSelectedIds,
duration,
videoRatio,
voiceIds,
previewCount = 1,
}: UseStep4PreviewProps) {
const templateName = useMemo(
() => templates.find((t) => t.id === selectedTemplate)?.name ?? "未选择",
[templates, selectedTemplate],
)
const materialCount = useMemo(() => {
if (materialMode === "auto") {
return `${smartSelectedIds.length} 个素材(智能匹配)`
}
return `${selectedMaterials.length} 个素材`
}, [materialMode, selectedMaterials.length, smartSelectedIds.length])
const materialTotal = materialMode === "auto" ? smartSelectedIds.length : selectedMaterials.length
/* ── 多预览状态 ── */
const [items, setItems] = useState<PreviewItem[]>(() =>
Array.from({ length: previewCount }, (_, i) => createInitialItem(i)),
)
const [selectedIndex, setSelectedIndex] = useState(0)
// 每个任务 ID + 轮询定时器,用于防止竞态条件(按 index 存储)
const taskIdsRef = useRef<Map<number, string>>(new Map())
const pollTimersRef = useRef<Map<number, ReturnType<typeof setTimeout>>>(new Map())
const startTimeRef = useRef<number>(0)
const clearPollTimer = useCallback((index?: number) => {
if (index !== undefined) {
const timer = pollTimersRef.current.get(index)
if (timer) {
clearTimeout(timer)
pollTimersRef.current.delete(index)
}
} else {
pollTimersRef.current.forEach((timer) => clearTimeout(timer))
pollTimersRef.current.clear()
}
}, [])
// 同步 previewCount 变化(增减项)
useEffect(() => {
setItems((prev) => {
if (prev.length === previewCount) return prev
if (prev.length > previewCount) return prev.slice(0, previewCount)
return [
...prev,
...Array.from({ length: previewCount - prev.length }, (_, i) =>
createInitialItem(prev.length + i),
),
]
})
// 如果 selectedIndex 超出范围,重置
setSelectedIndex((prev) => Math.min(prev, previewCount - 1))
}, [previewCount])
/* ── 参数变化时重置所有预览状态 ── */
const prevDepsRef = useRef({
selectedTemplate,
materialMode,
selectedMaterials: [...selectedMaterials].sort().join(","),
smartSelectedIds: [...smartSelectedIds].sort().join(","),
duration,
videoRatio,
voiceIds: [...(voiceIds || [])].sort().join(","),
})
useEffect(() => {
const currentKey = [
selectedTemplate,
materialMode,
[...selectedMaterials].sort().join(","),
[...smartSelectedIds].sort().join(","),
duration,
videoRatio,
[...(voiceIds || [])].sort().join(","),
].join("|")
const prevKey = [
prevDepsRef.current.selectedTemplate,
prevDepsRef.current.materialMode,
prevDepsRef.current.selectedMaterials,
prevDepsRef.current.smartSelectedIds,
prevDepsRef.current.duration,
prevDepsRef.current.videoRatio,
prevDepsRef.current.voiceIds,
].join("|")
if (prevKey !== currentKey && items.some((it) => it.status !== "idle")) {
taskIdsRef.current.clear()
clearPollTimer()
setItems(Array.from({ length: previewCount }, (_, i) => createInitialItem(i)))
setSelectedIndex(0)
}
prevDepsRef.current = {
selectedTemplate,
materialMode,
selectedMaterials: [...selectedMaterials].sort().join(","),
smartSelectedIds: [...smartSelectedIds].sort().join(","),
duration,
videoRatio,
voiceIds: [...(voiceIds || [])].sort().join(","),
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
selectedTemplate,
materialMode,
selectedMaterials,
smartSelectedIds,
duration,
videoRatio,
voiceIds,
previewCount,
])
// 组件卸载时清理所有轮询
useEffect(() => {
return () => {
clearPollTimer()
}
}, [clearPollTimer])
/** 轮询单个预览任务状态 */
const pollPreviewStatus = useCallback((index: number, taskId: string) => {
const poll = async () => {
// 竞态检查
if (taskIdsRef.current.get(index) !== taskId) return
// 超时检查
if (Date.now() - startTimeRef.current > POLL_TIMEOUT_MS) {
setItems((prev) =>
prev.map((it) =>
it.index === index ? { ...it, status: "error", error: "预览生成超时,请重试" } : it,
),
)
return
}
try {
const data: PreviewTaskResponse = await getPreviewStatus(taskId)
if (taskIdsRef.current.get(index) !== taskId) return
const status = data.status as ApiPreviewStatus
if (status === "completed") {
const result: PreviewResult = {
taskId: safeString(data.task_id, ""),
videoUrl: safeString(data.video_url, ""),
clipCount: safeNumber(data.clip_count),
transitionCount: safeNumber(data.transition_count),
materialUsage: safeNumber(data.material_usage),
duration: safeNumber(data.duration),
fileSize: safeNumber(data.file_size),
generateDuration: safeNumber(data.generate_duration),
progress: 100,
}
setItems((prev) =>
prev.map((it) =>
it.index === index ? { ...it, status: "ready", result, progress: 100 } : it,
),
)
return
}
if (status === "failed") {
setItems((prev) =>
prev.map((it) =>
it.index === index
? {
...it,
status: "error",
error: safeString(data.error_message, "预览生成失败,请重试"),
}
: it,
),
)
return
}
if (status === "cancelled") {
setItems((prev) =>
prev.map((it) =>
it.index === index ? { ...it, status: "error", error: "预览任务已取消" } : it,
),
)
return
}
// pending / generating 状态继续轮询
const prog = safeNumber(data.progress)
const nextStatus: PreviewStatus = status === "pending" ? "pending" : "generating"
setItems((prev) =>
prev.map((it) =>
it.index === index ? { ...it, status: nextStatus, progress: prog } : it,
),
)
const delay = status === "pending" ? 5000 : 2000
pollTimersRef.current.set(index, setTimeout(poll, delay))
} catch {
if (taskIdsRef.current.get(index) !== taskId) return
pollTimersRef.current.set(index, setTimeout(poll, 3000))
}
}
pollTimersRef.current.set(index, setTimeout(poll, 1000))
}, [])
/** 生成所有预览 */
const generatePreview = useCallback(async () => {
if (!selectedTemplate) {
setItems((prev) => prev.map((it) => ({ ...it, status: "error", error: "请先选择模板" })))
return
}
if (materialTotal === 0) {
setItems((prev) => prev.map((it) => ({ ...it, status: "error", error: "请先选择素材" })))
return
}
// 取消之前的所有轮询
clearPollTimer()
taskIdsRef.current.clear()
// 初始化所有项为 pending
setItems(
Array.from({ length: previewCount }, (_, i) => ({
index: i,
status: "pending" as PreviewStatus,
result: null,
error: "",
progress: 0,
})),
)
setSelectedIndex(0)
startTimeRef.current = Date.now()
const assetIds = materialMode === "auto" ? smartSelectedIds : selectedMaterials
// 并发创建所有预览任务
for (let i = 0; i < previewCount; i++) {
try {
const response = await createPreview({
template_id: selectedTemplate,
asset_ids: assetIds,
duration: duration || undefined,
video_ratio: videoRatio,
voice_ids: voiceIds && voiceIds.length > 0 ? voiceIds : undefined,
})
if (startTimeRef.current === 0) return
taskIdsRef.current.set(i, response.task_id)
pollPreviewStatus(i, response.task_id)
} catch (e) {
const errMsg = safeString(e instanceof Error ? e.message : e, "预览生成失败")
setItems((prev) =>
prev.map((it) => (it.index === i ? { ...it, status: "error", error: errMsg } : it)),
)
}
}
}, [
selectedTemplate,
materialTotal,
materialMode,
smartSelectedIds,
selectedMaterials,
duration,
videoRatio,
voiceIds,
previewCount,
clearPollTimer,
pollPreviewStatus,
])
/** 重新生成所有预览 */
const regeneratePreview = useCallback(() => {
generatePreview()
}, [generatePreview])
/** 是否所有预览都已完成 */
const allReady = items.length > 0 && items.every((it) => it.status === "ready")
/** 是否至少有一个预览已完成 */
const anyReady = items.some((it) => it.status === "ready")
/** 是否有任一正在生成中 */
const anyGenerating = items.some((it) => it.status === "pending" || it.status === "generating")
/** 当前选中的预览结果 */
const selectedResult = items[selectedIndex]?.result ?? null
/** 综合状态(兼容旧逻辑) */
const previewStatus: PreviewStatus = useMemo(() => {
if (items.every((it) => it.status === "idle")) return "idle"
if (items.some((it) => it.status === "pending" || it.status === "generating"))
return "generating"
if (allReady) return "ready"
if (items.every((it) => it.status === "error")) return "error"
// 部分完成部分出错
if (anyReady) return "ready"
return "error"
}, [items, allReady, anyReady])
/** 综合进度(取平均) */
const progress = useMemo(() => {
if (items.length === 0) return 0
return Math.round(items.reduce((sum, it) => sum + it.progress, 0) / items.length)
}, [items])
/** 综合错误信息 */
const previewError = useMemo(() => {
const errorItems = items.filter((it) => it.status === "error" && it.error)
if (errorItems.length === 0) return ""
if (errorItems.length === 1) return errorItems[0].error
return `${errorItems.length} 个预览生成失败`
}, [items])
const canProceed = anyReady
return {
templateName,
materialCount,
duration,
videoRatio,
// 多预览状态
items,
selectedIndex,
setSelectedIndex,
previewCount,
// 综合状态
previewStatus,
previewResult: selectedResult,
previewError,
progress,
canProceed,
allReady,
anyReady,
anyGenerating,
generatePreview,
regeneratePreview,
}
}
export default useStep4Preview
@@ -1,6 +1,7 @@
/**
* GeneratePage 步骤导航
* 管理步骤切换与各步骤的前置校验
* 步骤顺序:模板(1) → 素材(2) → 配音(3) → 预览(4) → 标题(5) → 封面(6) → 确认(7)
*/
import { message } from "antd"
import type { TitleSettings } from "../types"
@@ -13,7 +14,7 @@ export interface UseStepNavigationOptions {
selectedMaterials: string[]
smartSelectedIds: string[]
titleSettings: TitleSettings
/** Step3 是否已生成预览Step3校验用) */
/** Step4 是否已生成预览 */
previewReady: boolean
}
@@ -47,11 +48,12 @@ export const useStepNavigation = (options: UseStepNavigationOptions): UseStepNav
message.warning("请先进行智能匹配并选择素材")
return
}
if (currentStep === 3 && !previewReady) {
// Step3 配音:配音为可选项,不强制校验,用户可跳过
if (currentStep === 4 && !previewReady) {
message.warning("请先生成剪辑预览")
return
}
if (currentStep === 4 && !titleSettings.title.trim()) {
if (currentStep === 5 && !titleSettings.title.trim()) {
message.warning("请选择或输入标题")
return
}
@@ -17,7 +17,7 @@ import "@/api/generation/types"
// 直接引入所有 Step 组件,建立完整依赖链
import "@/pages/generate/GeneratePage"
import "@/pages/generate/components/Step2MaterialSelect"
import "@/pages/generate/components/Step3GeneratePreview"
import "@/pages/generate/components/Step4GeneratePreview"
import "@/pages/generate/components/Step4TitleSettings"
import "@/pages/generate/components/Step5VoiceSelect"
import "@/pages/generate/components/PreviewVideoPanel"
@@ -47,7 +47,7 @@ describe("GeneratePage module smoke test", () => {
})
})
import "@/pages/generate/hooks/useGenerateVideo"
import "@/pages/generate/hooks/useStep3Preview"
import "@/pages/generate/hooks/useStep4Preview"
import "@/pages/generate/hooks/generate-video/useGenerationPolling"
import "@/pages/generate/hooks/useGenerateFormState"
import "@/pages/generate/hooks/useGenerateFormState/useTemplateSelection"
@@ -1,17 +0,0 @@
/**
* Step3GeneratePreview smoke test
* 确保 vitest related 模式能匹配到第3步预览生成相关文件的改动
*/
import { describe, it, expect } from "vitest"
import "@/pages/generate/components/Step3GeneratePreview"
import "@/pages/generate/hooks/useStep3Preview"
import "@/pages/generate/hooks/useStepNavigation"
import "@/pages/generate/components/GenerateStepContent"
import "@/pages/generate/GeneratePage"
describe("Step3GeneratePreview module smoke test", () => {
it("should load all step3 preview modules", () => {
expect(true).toBe(true)
})
})
@@ -0,0 +1,17 @@
/**
* Step4GeneratePreview smoke test
* 确保 vitest related 模式能匹配到第4步预览生成相关文件的改动
*/
import { describe, it, expect } from "vitest"
import "@/pages/generate/components/Step4GeneratePreview"
import "@/pages/generate/hooks/useStep4Preview"
import "@/pages/generate/hooks/useStepNavigation"
import "@/pages/generate/components/GenerateStepContent"
import "@/pages/generate/GeneratePage"
describe("Step4GeneratePreview module smoke test", () => {
it("should load all step4 preview modules", () => {
expect(true).toBe(true)
})
})