feat: 预览改为前端实时播放,确认生成时一次服务器渲染 #1490

Merged
auto-approve-bot merged 1 commits from feat/frontend-realtime-preview into develop 2026-08-25 10:01:02 +08:00
5 changed files with 67 additions and 265 deletions
+23 -98
View File
@@ -1,21 +1,18 @@
/**
* 智能剪辑页面 — 服务器渲染预览架构
* 智能剪辑页面 — 前端实时预览架构
* 7 步向导:选择模板 → 素材 → 配音 → 标题 → 预览 → 封面 → 确认生成
* 左右布局:左侧 generate-form + 右侧 generate-preview
*
* 架构:
* - Step4+ 右侧预览面板自动创建服务器预览渲染任务(POST /generation/preview
* - 轮询完成后播放服务器渲染的真实视频(<video> 标签)
* - 标题样式编辑时 CSS 层实时叠加预览
* - 素材/配音/BGM 变更自动重新渲染;标题变更标记 stale
* - 点"确认生成"时走 confirm 路径,成品就是预览视频本身,100% 一致
* - Step4+ 右侧预览面板使用 FrontendPreviewPlayer 实时播放素材片段
* - 标题样式编辑时 CSS 层实时叠加预览,所见即所得
* - 点"确认生成"时调用 createGenerationTask 创建一次服务器渲染任务
*/
import React, { useMemo, useCallback } from "react"
import React, { useMemo } from "react"
import { Modal, message } from "antd"
import { useNavigate } from "react-router-dom"
import type { VoiceClone } from "@/api/voice-clone"
import { useCloneProgress } from "@/hooks/useCloneProgress"
import { calculateResolution } from "./utils/calculateResolution"
import CloneModal from "@/components/voice/CloneModal"
import GenerateHeader from "./components/GenerateHeader"
import {
@@ -24,14 +21,13 @@ import {
} from "./utils/calculateTotalVideoDuration"
import GenerateStepsBar from "./components/GenerateStepsBar"
import GenerateResultPanel from "./components/GenerateResultPanel"
import PreviewVideoPanel from "./components/PreviewVideoPanel"
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 { useServerPreview } from "./hooks/useServerPreview"
import { useTitleStyleUpdaters } from "./hooks/useStep4Title/useTitleStyleUpdaters"
import "./generate.css"
@@ -120,10 +116,7 @@ const GeneratePage: React.FC = () => {
[bgm, currentTemplate],
)
/* ── 输出分辨率(根据视频比例计算 ── */
const resolution = useMemo(() => calculateResolution(videoRatio), [videoRatio])
/* ── 加载素材详情(仅用于配音时长校验,不用于播放) ── */
/* ── 加载素材详情(供前端预览播放器使用 + 配音时长校验 ── */
const previewAssetsEnabled = previewAssetIds.length > 0
const { assets: previewAssets } = usePreviewAssets(previewAssetIds, previewAssetsEnabled)
@@ -134,74 +127,7 @@ const GeneratePage: React.FC = () => {
return estimateTotalVideoDuration(currentTemplate ?? undefined)
}, [previewAssets, currentTemplate])
/* ── 解析配音 ID ── */
const voiceLibraryId = useMemo(
() => (voiceMode === "clone" ? selectedClonedVoice || "" : selectedVoice || ""),
[voiceMode, selectedClonedVoice, selectedVoice],
)
/* ── 构建服务器预览请求参数 ── */
const buildPreviewRequest = useCallback(() => {
return {
template_id: selectedTemplate,
asset_ids: previewAssetIds,
duration: duration || 30,
video_ratio: videoRatio,
output_width: resolution.width,
output_height: resolution.height,
...(voiceLibraryId ? { voice_library_id: voiceLibraryId } : {}),
bgm_config: {
enabled: bgm !== false,
...(bgmConfig.music_id ? { preset_id: bgmConfig.music_id } : {}),
},
...(titleSettings.title
? {
title_config: {
text: titleSettings.title,
font: titleSettings.font,
font_size: titleSettings.size,
font_color: titleSettings.color,
position: titleSettings.position,
bold: titleSettings.bold,
stroke: titleSettings.stroke,
shadow: titleSettings.shadow,
},
}
: {}),
}
}, [
selectedTemplate,
previewAssetIds,
duration,
videoRatio,
resolution,
voiceLibraryId,
bgm,
bgmConfig,
titleSettings,
])
/* ── 服务器预览 ── */
const serverPreviewEnabled = currentStep >= 4 && !!selectedTemplate && previewAssetIds.length > 0
const {
status: previewStatus,
videoUrl: previewVideoUrl,
progress: previewProgress,
error: previewError,
triggerPreview,
} = useServerPreview({
enabled: serverPreviewEnabled,
buildRequest: buildPreviewRequest,
onPreviewTaskCreated: (taskId, planId) => {
setPreviewTaskId(taskId)
if (planId) setStoredSourceEditPlanId(planId)
},
})
/* ── 步骤导航 ── */
// Step5 需要服务器预览完成才能前进
const previewReady = previewStatus === "ready"
const { goNext, goPrev } = useStepNavigation({
currentStep,
setCurrentStep,
@@ -210,7 +136,6 @@ const GeneratePage: React.FC = () => {
selectedMaterials,
smartSelectedIds,
titleSettings,
previewReady,
})
/* ── 视频生成核心逻辑 ── */
@@ -250,11 +175,6 @@ const GeneratePage: React.FC = () => {
},
})
/* ── 手动重新预览(标题变更后或失败重试) ── */
const handleRetryPreview = useCallback(() => {
triggerPreview()
}, [triggerPreview])
/* ================================================================
渲染
================================================================ */
@@ -320,8 +240,6 @@ const GeneratePage: React.FC = () => {
onRetry={handleRetryGenerate}
onDismissError={handleDismissError}
presetVoices={presetVoices}
previewStatus={previewStatus}
onRetryPreview={handleRetryPreview}
/>
<GenerateStepActions
@@ -337,16 +255,23 @@ const GeneratePage: React.FC = () => {
{/* ════ 右侧:预览 + 结果 ════ */}
<div className="xx-generate-right-col">
{currentStep >= 4 && (
<PreviewVideoPanel
previewStatus={previewStatus}
videoUrl={previewVideoUrl}
progress={previewProgress}
error={previewError}
onRetry={handleRetryPreview}
{currentStep >= 4 && !!currentTemplate && (
<FrontendPreviewPlayer
assets={previewAssets}
template={currentTemplate}
videoRatio={videoRatio}
titleSettings={titleSettings}
assetCount={previewAssetIds.length}
ready={previewAssets.length > 0}
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 && (
@@ -84,10 +84,6 @@ export interface GenerateStepContentProps {
bgm: boolean
/** BGM 配置(来自模板) */
bgmConfig?: { enabled: boolean; music_id?: string }
/** 服务器预览状态 */
previewStatus?: import("../hooks/useServerPreview").ServerPreviewStatus
/** 重新预览回调 */
onRetryPreview?: () => void
}
export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) => {
@@ -137,8 +133,6 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
onSourceEditPlanIdExtracted,
bgm,
bgmConfig,
previewStatus,
onRetryPreview,
} = props
/* 当前模板的 segments,传给 Step2 构建 clips */
@@ -197,8 +191,6 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
onApplyPreset={onApplyPreset}
activePreset={activePreset}
titlePresets={titlePresets}
previewStatus={previewStatus || "idle"}
onRetryPreview={onRetryPreview || (() => {})}
/>
)
case 6:
@@ -1,18 +1,15 @@
/**
* Step 5 预览设置组件
*
* 服务器渲染预览架构:
* - 进入此步骤时右侧面板自动播放服务器渲染的真实视频
* - 标题样式可实时调整CSS 层叠加预览
* - 调整标题后点击"重新预览"可刷新服务器渲染结果
* - 素材/配音/BGM 变更会自动重新渲染
* 前端实时预览架构:
* - 右侧面板使用 FrontendPreviewPlayer 实时播放素材片段
* - 标题样式可实时调整CSS 层即时叠加预览
* - 点"确认生成"时触发一次服务器渲染
*/
import React from "react"
import { PlayCircleOutlined, ReloadOutlined } from "@ant-design/icons"
import { Button } from "antd"
import { PlayCircleOutlined } from "@ant-design/icons"
import { POSITION_OPTIONS, FONT_OPTIONS } from "../constants"
import type { TitleSettings } from "../types"
import type { ServerPreviewStatus } from "../hooks/useServerPreview"
import TitleStylePanel from "./title/TitleStylePanel"
interface Step5GeneratePreviewProps {
@@ -27,10 +24,6 @@ interface Step5GeneratePreviewProps {
onApplyPreset: (presetKey: string) => void
activePreset: string | null
titlePresets: { key: string; label: string; previewStyle: React.CSSProperties }[]
/** 服务器预览状态 */
previewStatus: ServerPreviewStatus
/** 重新预览回调 */
onRetryPreview: () => void
}
const Step5GeneratePreview: React.FC<Step5GeneratePreviewProps> = ({
@@ -45,13 +38,7 @@ const Step5GeneratePreview: React.FC<Step5GeneratePreviewProps> = ({
onApplyPreset,
activePreset,
titlePresets,
previewStatus,
onRetryPreview,
}) => {
const isLoading = previewStatus === "loading"
const isStale = previewStatus === "stale"
const isFailed = previewStatus === "failed"
return (
<div className="xx-form-section">
<h3>🎬 </h3>
@@ -70,71 +57,10 @@ const Step5GeneratePreview: React.FC<Step5GeneratePreviewProps> = ({
>
<PlayCircleOutlined style={{ fontSize: 18, color: "#3b82f6" }} />
<span style={{ fontSize: 13, color: "var(--text-secondary, #666)" }}>
</span>
</div>
{/* 配置变更提示条 */}
{isStale && (
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 8,
padding: "10px 16px",
background: "rgba(250, 173, 20, 0.1)",
borderRadius: 8,
marginBottom: 16,
border: "1px solid rgba(250, 173, 20, 0.2)",
}}
>
<span style={{ fontSize: 13, color: "#d48806" }}>
</span>
<Button size="small" type="primary" icon={<ReloadOutlined />} onClick={onRetryPreview}>
</Button>
</div>
)}
{isFailed && (
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 8,
padding: "10px 16px",
background: "rgba(255, 77, 79, 0.1)",
borderRadius: 8,
marginBottom: 16,
border: "1px solid rgba(255, 77, 79, 0.2)",
}}
>
<span style={{ fontSize: 13, color: "#ff4d4f" }}></span>
<Button size="small" danger icon={<ReloadOutlined />} onClick={onRetryPreview}>
</Button>
</div>
)}
{isLoading && (
<div
style={{
padding: "10px 16px",
background: "rgba(82, 196, 26, 0.08)",
borderRadius: 8,
marginBottom: 16,
border: "1px solid rgba(82, 196, 26, 0.15)",
}}
>
<span style={{ fontSize: 13, color: "#389e0d" }}>
...
</span>
</div>
)}
<TitleStylePanel
settings={titleSettings}
onUpdatePosition={onUpdatePosition}
@@ -6,7 +6,6 @@ import { useState, useCallback } from "react"
import { message } from "antd"
import type { GeneratedVideo } from "@/api/template-editor"
import { createGenerationTask } from "@/api/tasks/tasks"
import { confirmGeneration } from "@/api/generation/confirm"
import type { UseGenerateVideoProps } from "./generate-video/types"
import { getGenerationPhase } from "./generate-video/phase"
import { useGenerationPolling } from "./generate-video/useGenerationPolling"
@@ -61,7 +60,7 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
clearTimer()
try {
// 解析分辨率(共享工具函数,与预览 API 一致
// 解析分辨率(共享工具函数)
const { width: outputWidth, height: outputHeight } = calculateResolution(
props.videoRatio || "9:16",
)
@@ -72,78 +71,45 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
// 封面 URL:优先 AI 生成缩略图,兜底用户上传
const coverUrl = props.coverSettings?.thumbnail_url || props.coverSettings?.upload_url || ""
// ── Step7 确认生成:优先复用预览产物(秒出),fallback 到全量渲染 ──
let taskId: string | undefined
// 主路径:如果有预览任务 ID 且只生成 1 个视频,调用 confirmGeneration 复用预览产物
// generateCount > 1 时需要走 createGenerationTask 支持批量生成
const isSingleGenerate = !props.generateCount || props.generateCount === 1
if (props.previewTaskId && isSingleGenerate) {
try {
console.log(
"[handleGenerate] 尝试 confirmGeneration 复用预览产物, previewTaskId:",
props.previewTaskId,
)
const confirmResp = await confirmGeneration(props.previewTaskId, {
output_width: outputWidth,
output_height: outputHeight,
cover_url: coverUrl,
custom_title: props.titleSettings?.title || "",
})
taskId = confirmResp.items?.[0]?.id
if (taskId) {
console.log("[handleGenerate] confirmGeneration 成功, taskId:", taskId)
}
} catch (confirmErr) {
console.warn(
"[handleGenerate] confirmGeneration 失败, fallback 到 createGenerationTask:",
confirmErr,
)
// 继续走 fallback 路径
}
}
// 解析配音参数:voiceMode=clone 时用 selectedClonedVoice,否则用 selectedVoice(配音素材库 asset ID
// 解析配音参数:voiceMode=clone 时用 selectedClonedVoice,否则用 selectedVoice
const voiceLibraryId =
props.voiceMode === "clone" ? props.selectedClonedVoice || "" : props.selectedVoice || ""
// Fallback 路径:没有预览任务或 confirmGeneration 失败,创建新的生成任务
if (!taskId) {
const taskResp = await createGenerationTask({
template_id: selectedTemplate,
asset_ids: assetIds,
output_width: outputWidth,
output_height: outputHeight,
cover_url: coverUrl,
custom_title: props.titleSettings?.title || "",
duration: props.duration || undefined,
video_ratio: props.videoRatio,
// 配音:优先用 voice_library_id(配音素材库 asset),兜底 voice_ids
...(voiceLibraryId ? { voice_library_id: voiceLibraryId } : {}),
...(props.selectedVoice && !voiceLibraryId ? { voice_ids: [props.selectedVoice] } : {}),
// BGM 配置:受 bgm 开关控制,enabled=false 时也显式传覆盖模板 BGM
bgm_config: {
enabled: props.bgm !== false,
...(props.bgmConfig?.music_id ? { preset_id: props.bgmConfig.music_id } : {}),
},
...(props.sourceEditPlanId ? { source_edit_plan_id: props.sourceEditPlanId } : {}),
...(props.titleSettings?.title
? {
title_config: {
text: props.titleSettings.title,
font: props.titleSettings.font,
font_size: props.titleSettings.size,
font_color: props.titleSettings.color,
position: props.titleSettings.position,
bold: props.titleSettings.bold,
stroke: props.titleSettings.stroke,
shadow: props.titleSettings.shadow,
},
}
: {}),
})
taskId = taskResp.items?.[0]?.id
}
// 创建生成任务(服务器渲染)
const taskResp = await createGenerationTask({
template_id: selectedTemplate,
asset_ids: assetIds,
output_width: outputWidth,
output_height: outputHeight,
cover_url: coverUrl,
custom_title: props.titleSettings?.title || "",
duration: props.duration || undefined,
video_ratio: props.videoRatio,
// 配音:优先用 voice_library_id(配音素材库 asset),兜底 voice_ids
...(voiceLibraryId ? { voice_library_id: voiceLibraryId } : {}),
...(props.selectedVoice && !voiceLibraryId ? { voice_ids: [props.selectedVoice] } : {}),
// BGM 配置:受 bgm 开关控制,enabled=false 时也显式传覆盖模板 BGM
bgm_config: {
enabled: props.bgm !== false,
...(props.bgmConfig?.music_id ? { preset_id: props.bgmConfig.music_id } : {}),
},
...(props.sourceEditPlanId ? { source_edit_plan_id: props.sourceEditPlanId } : {}),
...(props.titleSettings?.title
? {
title_config: {
text: props.titleSettings.title,
font: props.titleSettings.font,
font_size: props.titleSettings.size,
font_color: props.titleSettings.color,
position: props.titleSettings.position,
bold: props.titleSettings.bold,
stroke: props.titleSettings.stroke,
shadow: props.titleSettings.shadow,
},
}
: {}),
})
const taskId = taskResp.items?.[0]?.id
if (!taskId) {
throw new Error("创建任务成功但未返回任务 ID,请稍后在任务列表查看")
@@ -3,7 +3,7 @@
* 管理步骤切换与各步骤的前置校验
* 步骤顺序:模板(1) → 素材(2) → 配音(3) → 标题(4) → 预览(5) → 封面(6) → 确认(7)
*
* 服务器预览架构:Step5 需要服务器渲染预览完成才能前进
* 前端实时预览架构:Step5 无需等待服务器渲染
*/
import { message } from "antd"
import type { TitleSettings } from "../types"
@@ -16,8 +16,6 @@ export interface UseStepNavigationOptions {
selectedMaterials: string[]
smartSelectedIds: string[]
titleSettings: TitleSettings
/** 服务器预览是否已完成(ready 状态) */
previewReady: boolean
}
export interface UseStepNavigationReturn {
@@ -34,7 +32,6 @@ export const useStepNavigation = (options: UseStepNavigationOptions): UseStepNav
selectedMaterials,
smartSelectedIds,
titleSettings,
previewReady,
} = options
const goNext = () => {
@@ -54,10 +51,6 @@ export const useStepNavigation = (options: UseStepNavigationOptions): UseStepNav
message.warning("请选择或输入标题")
return
}
if (currentStep === 5 && !previewReady) {
message.warning("请等待预览视频渲染完成后再继续")
return
}
if (currentStep < 7) {
setCurrentStep((s) => s + 1)
}