feat: 预览改为服务器渲染视频,实现所见即所得 #1487

Merged
xiaoxia merged 1 commits from feat/server-rendered-preview into develop 2026-08-25 01:00:55 +08:00
6 changed files with 657 additions and 149 deletions
+98 -51
View File
@@ -1,21 +1,20 @@
/**
* 智能剪辑页面 — V24 前端预览播放器架构改造
* 智能剪辑页面 — 服务器渲染预览架构
* 7 步向导:选择模板 → 素材 → 配音 → 标题 → 预览 → 封面 → 确认生成
* 左右布局:左侧 generate-form + 右侧 generate-preview
*
* 架构改造
* - Step5 预览改为前端素材切片播放(FrontendPreviewPlayer
* - 完全去除后端 FFmpeg 预览依赖
* - 标题样式通过 CSS 层实时叠加,所见即所得
* - 最终成片仍走后端 FFmpeg 渲染(Step7 确认生成)
* 架构:
* - Step4+ 右侧预览面板自动创建服务器预览渲染任务(POST /generation/preview
* - 轮询完成后播放服务器渲染的真实视频(<video> 标签)
* - 标题样式编辑时 CSS 层实时叠加预览
* - 素材/配音/BGM 变更自动重新渲染;标题变更标记 stale
* - 点"确认生成"时走 confirm 路径,成品就是预览视频本身,100% 一致
*/
import React, { useMemo } from "react"
import React, { useMemo, useCallback } 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 { getAssetsByKind } from "@/api/assets"
import CloneModal from "@/components/voice/CloneModal"
import GenerateHeader from "./components/GenerateHeader"
import {
@@ -31,6 +30,7 @@ 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"
@@ -83,7 +83,7 @@ const GeneratePage: React.FC = () => {
setStoredSourceEditPlanId,
} = formState
/* ── 标题样式回调(Step5 样式面板 + 右侧预览 CSS 层共用) ── */
/* ── 标题样式回调 ── */
const styleUpdaters = useTitleStyleUpdaters({
titleSettings,
onTitleSettingsChange: setTitleSettings,
@@ -98,30 +98,19 @@ const GeneratePage: React.FC = () => {
message.success("音色克隆成功!")
}
/* ── 前端预览:加载选中素材的视频文件信息 ── */
/* ── 素材 ID 列表 ── */
const previewAssetIds = useMemo(
() => (materialMode === "auto" ? smartSelectedIds : selectedMaterials),
[materialMode, smartSelectedIds, selectedMaterials],
)
// previewTaskId 不再因设置变化而自动清除。
// 用户修改设置后,confirmGeneration 会检测参数是否匹配:
// 匹配则复用产物秒出;不匹配则后端自动 fallback 到 createGenerationTask 重新渲染。
// 只有当用户明确返回 Step5 重新预览时,才会创建新的预览任务并覆盖 previewTaskId。
const previewAssetsEnabled = currentStep >= 4 && previewAssetIds.length > 0
const {
assets: previewAssets,
loading: previewAssetsLoading,
ready: previewAssetsReady,
} = usePreviewAssets(previewAssetIds, previewAssetsEnabled)
/* ── 当前模板对象(传给前端预览播放器) ── */
/* ── 当前模板对象 ── */
const currentTemplate = useMemo(
() => userTemplates.find((t) => t.id === selectedTemplate) || null,
[userTemplates, selectedTemplate],
)
/* ── BGM 配置:从当前模板读取,受 bgm 开关控制 ── */
/* ── BGM 配置 ── */
const bgmConfig = useMemo(
() => ({
enabled: bgm,
@@ -130,27 +119,82 @@ const GeneratePage: React.FC = () => {
[bgm, currentTemplate],
)
/* ── 视频总时长计算(用于配音时长校验) ── */
/* ── 加载素材详情(仅用于配音时长校验,不用于播放 ── */
const previewAssetsEnabled = previewAssetIds.length > 0
const { assets: previewAssets } = usePreviewAssets(previewAssetIds, previewAssetsEnabled)
/* ── 视频总时长计算 ── */
const totalVideoDuration = useMemo(() => {
// 优先用素材精确时长;素材未加载时用模板 segments 的 duration_max 之和估算
const exact = calculateTotalVideoDuration(previewAssets, currentTemplate ?? undefined)
if (exact > 0) return exact
return estimateTotalVideoDuration(currentTemplate ?? undefined)
}, [previewAssets, currentTemplate])
/* ── 配音音频 URL ── */
const { data: voiceMaterials = [] } = useQuery({
queryKey: ["assets", "voice"],
queryFn: () => getAssetsByKind("voice", { limit: 50 }),
/* ── 解析配音 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,
...(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,
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)
},
})
const voiceAudioUrl = useMemo(() => {
if (!selectedVoice) return undefined
const asset = voiceMaterials.find((v) => v.id === selectedVoice)
return asset?.file_url || undefined
}, [selectedVoice, voiceMaterials])
/* ── 步骤导航 ── */
// Step5 需要服务器预览完成才能前进
const previewReady = previewStatus === "ready"
const { goNext, goPrev } = useStepNavigation({
currentStep,
setCurrentStep,
@@ -159,7 +203,7 @@ const GeneratePage: React.FC = () => {
selectedMaterials,
smartSelectedIds,
titleSettings,
previewReady: previewAssetsReady,
previewReady,
})
/* ── 视频生成核心逻辑 ── */
@@ -199,19 +243,21 @@ const GeneratePage: React.FC = () => {
},
})
/* ── 手动重新预览(标题变更后或失败重试) ── */
const handleRetryPreview = useCallback(() => {
triggerPreview()
}, [triggerPreview])
/* ================================================================
渲染 — 主页面
渲染
================================================================ */
return (
<div className="xx-generate-page">
{/* ── 页头 ── */}
<GenerateHeader fromEditPlan={!!editPlanId} />
{/* ── 步骤条 ── */}
<GenerateStepsBar currentStep={currentStep} onStepClick={setCurrentStep} />
{/* ── 主布局 ── */}
<div className="xx-generate-layout">
{/* ════ 左侧:表单区 ════ */}
<div className="xx-generate-form">
@@ -228,7 +274,6 @@ const GeneratePage: React.FC = () => {
onSmartSelectedIdsChange={setSmartSelectedIds}
titleSettings={titleSettings}
onTitleSettingsChange={setTitleSettings}
/* 标题样式回调 */
onUpdatePosition={styleUpdaters.updatePosition}
onUpdateFont={styleUpdaters.updateFont}
onUpdateSize={styleUpdaters.updateSize}
@@ -268,6 +313,8 @@ const GeneratePage: React.FC = () => {
onRetry={handleRetryGenerate}
onDismissError={handleDismissError}
presetVoices={presetVoices}
previewStatus={previewStatus}
onRetryPreview={handleRetryPreview}
/>
<GenerateStepActions
@@ -281,18 +328,18 @@ const GeneratePage: React.FC = () => {
/>
</div>
{/* ════ 右侧:预览 + 生成结果 ════ */}
{/* ════ 右侧:预览 + 结果 ════ */}
<div className="xx-generate-right-col">
{/* 预览视频面板(Step4+ 显示,含 CSS 标题实时预览层) */}
{currentStep >= 4 && (
<PreviewVideoPanel
assets={previewAssets}
template={currentTemplate}
previewStatus={previewStatus}
videoUrl={previewVideoUrl}
progress={previewProgress}
error={previewError}
onRetry={handleRetryPreview}
videoRatio={videoRatio}
assetsReady={previewAssetsReady}
assetsLoading={previewAssetsLoading}
titleSettings={titleSettings}
voiceAudioUrl={voiceAudioUrl}
assetCount={previewAssetIds.length}
/>
)}
{currentStep >= 6 && (
@@ -314,7 +361,7 @@ const GeneratePage: React.FC = () => {
</div>
</div>
{/* ── 视频预览弹窗 ── */}
{/* 视频预览弹窗 */}
<Modal
className="xx-preview-modal"
open={previewModalOpen}
@@ -337,7 +384,7 @@ const GeneratePage: React.FC = () => {
)}
</Modal>
{/* ── 音色克隆弹窗 ── */}
{/* 音色克隆弹窗 */}
<CloneModal
open={cloneModalOpen}
onClose={() => setCloneModalOpen(false)}
@@ -84,6 +84,10 @@ 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) => {
@@ -133,6 +137,8 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
onSourceEditPlanIdExtracted,
bgm,
bgmConfig,
previewStatus,
onRetryPreview,
} = props
/* 当前模板的 segments,传给 Step2 构建 clips */
@@ -191,6 +197,8 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
onApplyPreset={onApplyPreset}
activePreset={activePreset}
titlePresets={titlePresets}
previewStatus={previewStatus || "idle"}
onRetryPreview={onRetryPreview || (() => {})}
/>
)
case 6:
@@ -1,39 +1,42 @@
/**
* 右侧预览视频面板
* Step4+: 显示预览视频面板
* Step5: 前端实时预览 — 用原生 video 播放素材片段 + CSS 标题叠加
* 右侧预览视频面板 — 服务器渲染预览架构
*
* 架构改造:完全去除后端 FFmpeg 预览依赖
* - 使用 FrontendPreviewPlayer 直接播放素材片段
* - TitleOverlay CSS 层实时响应标题样式变化
* Step4+: 显示预览面板
* Step5: 播放服务器渲染的真实视频(POST /generation/preview
*
* 布局:本组件提供 .xx-preview-video 容器(position: relative + overflow: hidden
* FrontendPreviewPlayer 的内容通过 absolute 定位填充容器
* TitleOverlay 通过 absolute 定位 + z-index: 30 覆盖在最上层
* 架构:
* - 进入 Step4/5 时自动创建服务器预览渲染任务
* - 轮询完成后用 <video> 标签播放返回的 video_url
* - 标题样式编辑时 CSS TitleOverlay 实时叠加预览
* - 素材/配音/BGM 变更自动重新渲染
* - 标题文字/样式变更标记 stale,保留旧视频 + 显示"重新预览"按钮
*
* 点"确认生成"时走 confirm 路径,成品就是预览视频本身,100% 一致。
*/
import React, { useMemo, useRef, useState, useEffect } from "react"
import { LoadingOutlined } from "@ant-design/icons"
import type { AssetItem } from "@/api/assets"
import type { EditingTemplate } from "@/api/editing-planner"
import { LoadingOutlined, ReloadOutlined, ExclamationCircleOutlined } from "@ant-design/icons"
import { Button } from "antd"
import type { TitleSettings } from "../types"
import { getFontFamily } from "../constants"
import FrontendPreviewPlayer from "./FrontendPreviewPlayer"
import type { ServerPreviewStatus } from "../hooks/useServerPreview"
interface PreviewVideoPanelProps {
/** 已加载的素材列表 */
assets: AssetItem[]
/** 当前模板 */
template: EditingTemplate | null
/** 服务器预览状态 */
previewStatus: ServerPreviewStatus
/** 服务器渲染视频 URL */
videoUrl: string | null
/** 渲染进度 0-100 */
progress: number
/** 错误信息 */
error: string | null
/** 重新预览回调 */
onRetry: () => void
/** 视频比例 */
videoRatio: string
/** 素材是否已加载就绪 */
assetsReady: boolean
/** 素材是否正在加载 */
assetsLoading: boolean
/** 标题设置 — 用于 CSS 实时预览层 */
/** 标题设置 — CSS 实时预览层 */
titleSettings?: TitleSettings
/** 配音音频 URL */
voiceAudioUrl?: string
/** 素材数量 */
assetCount?: number
}
/* ── ASS 坐标系参数(与后端 ass_subtitle_builder.py 一致) ── */
@@ -42,13 +45,8 @@ const ASS_TITLE_MARGIN_TOP = 60
const ASS_TITLE_MARGIN_BOTTOM = 60
const ASS_TITLE_MARGIN_SIDE = 40
/**
* 根据 position 计算 CSS 垂直定位
* 与后端 position_to_ass_alignment() 对齐:top→8, center→5, bottom→2
*/
function getPositionStyle(position: string): React.CSSProperties {
const sidePercent = (ASS_TITLE_MARGIN_SIDE / 1280) * 100
switch (position) {
case "bottom":
return {
@@ -76,16 +74,11 @@ function getPositionStyle(position: string): React.CSSProperties {
}
}
/**
* 构建 CSS 标题层的样式
* 所有渲染参数与后端 FFmpeg ASS 字幕一致
*/
function buildTitleStyle(settings: TitleSettings, containerHeight: number): React.CSSProperties {
// 用 px 计算 fontSize,不再依赖父元素 font-size 的百分比
const fontSizePx =
containerHeight > 0
? (Math.min(settings.size, 96) / ASS_VIDEO_HEIGHT) * containerHeight
: (Math.min(settings.size, 96) / ASS_VIDEO_HEIGHT) * 400 // fallback
: (Math.min(settings.size, 96) / ASS_VIDEO_HEIGHT) * 400
const base: React.CSSProperties = {
fontFamily: getFontFamily(settings.font),
@@ -100,28 +93,16 @@ function buildTitleStyle(settings: TitleSettings, containerHeight: number): Reac
paddingLeft: `${(ASS_TITLE_MARGIN_SIDE / 1280) * 100}%`,
paddingRight: `${(ASS_TITLE_MARGIN_SIDE / 1280) * 100}%`,
}
if (settings.stroke) {
base.WebkitTextStroke = "1px #000000"
}
if (settings.shadow) {
base.textShadow = "2px 2px 4px rgba(0,0,0,0.8)"
}
if (settings.stroke) base.WebkitTextStroke = "1px #000000"
if (settings.shadow) base.textShadow = "2px 2px 4px rgba(0,0,0,0.8)"
return base
}
/**
* CSS 标题预览覆盖层
* 始终渲染:有标题显示标题,无标题显示占位文本"标题预览"
* z-index: 20(在视频 z-index:1 和控制条 z-index:10 之上)
*/
/** CSS 标题实时预览覆盖层 */
const TitleOverlay: React.FC<{ titleSettings: TitleSettings }> = ({ titleSettings }) => {
const containerRef = useRef<HTMLDivElement>(null)
const [containerHeight, setContainerHeight] = useState(400) // fallback
const [containerHeight, setContainerHeight] = useState(400)
// ResizeObserver 获取容器实际高度
useEffect(() => {
const el = containerRef.current
if (!el) return
@@ -132,7 +113,6 @@ const TitleOverlay: React.FC<{ titleSettings: TitleSettings }> = ({ titleSetting
}
})
ro.observe(el)
// 初始化也读一次
const rect = el.getBoundingClientRect()
if (rect.height > 0) setContainerHeight(rect.height)
return () => ro.disconnect()
@@ -144,7 +124,7 @@ const TitleOverlay: React.FC<{ titleSettings: TitleSettings }> = ({ titleSetting
)
const titleStyle = useMemo(
() => buildTitleStyle(titleSettings, containerHeight),
// eslint-disable-next-line react-hooks/exhaustive-deps -- 已逐字段列出 titleSettings 依赖
// eslint-disable-next-line react-hooks/exhaustive-deps
[
containerHeight,
titleSettings.font,
@@ -170,13 +150,7 @@ const TitleOverlay: React.FC<{ titleSettings: TitleSettings }> = ({ titleSetting
overflow: "hidden",
}}
>
<div
style={{
...positionStyle,
...titleStyle,
position: "absolute",
}}
>
<div style={{ ...positionStyle, ...titleStyle, position: "absolute" }}>
{displayTitle.split("/").map((part, i) => (
<span key={i}>
{i > 0 && <br />}
@@ -191,30 +165,47 @@ const TitleOverlay: React.FC<{ titleSettings: TitleSettings }> = ({ titleSetting
/* ── 主组件 ── */
export const PreviewVideoPanel: React.FC<PreviewVideoPanelProps> = ({
assets,
template,
previewStatus,
videoUrl,
progress,
error,
onRetry,
videoRatio,
assetsReady,
assetsLoading,
titleSettings,
voiceAudioUrl,
assetCount,
}) => {
const videoAspectStyle = { aspectRatio: (videoRatio || "9:16").replace(":", "/") }
const isLoading = previewStatus === "loading"
const isReady = previewStatus === "ready" || previewStatus === "stale"
const isFailed = previewStatus === "failed"
const isIdle = previewStatus === "idle"
const isStale = previewStatus === "stale"
return (
<div className="xx-generate-preview">
<div className="xx-preview-header">
<h3></h3>
{assetsReady && assets.length > 0 && <span className="xx-preview-badge"></span>}
{isReady && !isStale && <span className="xx-preview-badge"></span>}
{isStale && (
<span
style={{
fontSize: 12,
color: "#faad14",
display: "flex",
alignItems: "center",
gap: 4,
}}
>
<ExclamationCircleOutlined />
</span>
)}
{isLoading && <span className="xx-preview-badge"></span>}
</div>
{/* ✅ 预览容器 — 唯一的 .xx-preview-video 容器
内部所有内容(视频、控制条、标题叠加层)通过 absolute 定位填充 */}
<div className="xx-preview-video" style={{ ...videoAspectStyle, position: "relative" }}>
{/* 加载中状态 */}
{assetsLoading && (
{/* 加载中 */}
{isLoading && (
<div
className="xx-preview-loading-center"
style={{
position: "absolute",
inset: 0,
@@ -223,34 +214,130 @@ export const PreviewVideoPanel: React.FC<PreviewVideoPanelProps> = ({
alignItems: "center",
justifyContent: "center",
zIndex: 5,
background: "rgba(0,0,0,0.6)",
}}
>
<LoadingOutlined style={{ fontSize: 36, color: "#fff" }} spin />
<p style={{ marginTop: 12, color: "rgba(255,255,255,0.8)", fontSize: 14 }}>
...
<p style={{ marginTop: 12, color: "rgba(255,255,255,0.9)", fontSize: 14 }}>
{progress > 0 ? `...${progress}%` : "..."}
</p>
<p style={{ marginTop: 4, color: "rgba(255,255,255,0.5)", fontSize: 12 }}>
30-60
</p>
</div>
)}
{/* 前端播放器(视频 + 控制条 + 播放按钮)*/}
<FrontendPreviewPlayer
assets={assets}
template={template}
videoRatio={videoRatio}
ready={assetsReady}
voiceAudioUrl={voiceAudioUrl}
/>
{/* 空闲状态(尚未触发预览) */}
{isIdle && (
<div
style={{
position: "absolute",
inset: 0,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
background: "rgba(0,0,0,0.3)",
zIndex: 5,
}}
>
<p style={{ color: "rgba(255,255,255,0.7)", fontSize: 14 }}>...</p>
</div>
)}
{/* CSS 标题实时预览层 — z-index: 20,始终渲染在内容层之上 */}
{titleSettings && <TitleOverlay titleSettings={titleSettings} />}
{/* 服务器渲染的真实视频 */}
{isReady && videoUrl && (
<video
key={videoUrl}
src={videoUrl}
controls
autoPlay
loop
playsInline
style={{
width: "100%",
height: "100%",
objectFit: "contain",
position: "absolute",
inset: 0,
zIndex: 1,
}}
/>
)}
{/* 标题样式实时预览层(仅在有视频时叠加) */}
{isReady && titleSettings && <TitleOverlay titleSettings={titleSettings} />}
{/* stale 遮罩:配置变更提示 */}
{isStale && (
<div
style={{
position: "absolute",
bottom: 0,
left: 0,
right: 0,
padding: "10px 16px",
background: "linear-gradient(transparent, rgba(0,0,0,0.85))",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 8,
zIndex: 30,
}}
>
<span style={{ color: "rgba(255,255,255,0.9)", fontSize: 12 }}>
</span>
<Button size="small" type="primary" icon={<ReloadOutlined />} onClick={onRetry}>
</Button>
</div>
)}
{/* 错误状态 */}
{isFailed && (
<div
style={{
position: "absolute",
inset: 0,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
background: "rgba(0,0,0,0.7)",
zIndex: 10,
padding: 24,
}}
>
<ExclamationCircleOutlined style={{ fontSize: 40, color: "#ff4d4f" }} />
<p
style={{
marginTop: 12,
color: "rgba(255,255,255,0.9)",
fontSize: 14,
textAlign: "center",
}}
>
{error || "预览渲染失败"}
</p>
<Button
type="primary"
icon={<ReloadOutlined />}
onClick={onRetry}
style={{ marginTop: 12 }}
>
</Button>
</div>
)}
</div>
{/* 素材信息 */}
{assetsReady && assets.length > 0 && (
{assetCount !== undefined && assetCount > 0 && (
<div className="xx-preview-info">
<div className="xx-preview-info-row">
<span></span>
<span>{assets.length} </span>
<span>{assetCount} </span>
</div>
<div className="xx-preview-info-row">
<span></span>
@@ -1,16 +1,21 @@
/**
* Step 5 生成预览组件
* 架构改造:移除后端预览生成,改为前端实时预览
* 左侧仅保留标题样式面板,视频在右侧 PreviewVideoPanel 实时播放
* Step 5 预览设置组件
*
* 服务器渲染预览架构:
* - 进入此步骤时右侧面板自动播放服务器渲染的真实视频
* - 标题样式可实时调整(CSS 层叠加预览)
* - 调整标题后点击"重新预览"可刷新服务器渲染结果
* - 素材/配音/BGM 变更会自动重新渲染
*/
import React from "react"
import { PlayCircleOutlined } from "@ant-design/icons"
import { PlayCircleOutlined, ReloadOutlined } from "@ant-design/icons"
import { Button } from "antd"
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 {
/* 标题样式 */
titleSettings: TitleSettings
onUpdatePosition: (position: string) => void
onUpdateFont: (font: string) => void
@@ -22,6 +27,10 @@ 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> = ({
@@ -36,14 +45,18 @@ 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>
{/* 前端实时预览提示 */}
<div
className="xx-preview-tip"
style={{
display: "flex",
alignItems: "center",
@@ -57,11 +70,71 @@ 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}
@@ -0,0 +1,294 @@
/**
* 服务器渲染预览 Hook
*
* 核心职责:
* 1. 调用 POST /generation/preview 创建服务器预览渲染任务
* 2. 轮询 GET /generation/preview/{task_id} 直到完成
* 3. 返回服务器渲染的真实视频 URL(供 <video> 标签播放)
* 4. 检测配置变更,标记预览失效(stale)或自动重新渲染
* 5. 网络错误自动重试 2 次
*
* 状态机:
* idle → loading → ready → stale (config changed)
* ↘ failed → idle (retry)
*/
import { useState, useCallback, useRef, useEffect } from "react"
import { createPreview, getPreviewStatus } from "@/api/generation/preview"
import type { CreatePreviewRequest } from "@/api/generation/types"
export type ServerPreviewStatus = "idle" | "loading" | "ready" | "stale" | "failed"
interface UseServerPreviewOptions {
/** 是否启用预览(Step4+ 且有素材和模板时) */
enabled: boolean
/** 构建预览请求参数(每次 render 调用,获取最新配置) */
buildRequest: () => CreatePreviewRequest
/** 预览任务创建成功回调 */
onPreviewTaskCreated?: (taskId: string, sourceEditPlanId?: string) => void
}
interface UseServerPreviewReturn {
status: ServerPreviewStatus
videoUrl: string | null
error: string | null
/** 进度 0-100 */
progress: number
/** 手动触发预览创建("重新预览"按钮或标题变更后手动刷新) */
triggerPreview: () => void
/** 当前预览任务 ID */
taskId: string | null
}
const POLL_INTERVAL = 2000
const POLL_TIMEOUT = 120_000
const MAX_NETWORK_RETRIES = 2
/**
* 对配置参数做指纹,用于检测配置是否变化
*/
function buildFingerprint(req: CreatePreviewRequest): string {
return JSON.stringify({
t: req.template_id,
a: [...req.asset_ids].sort(),
d: req.duration,
r: req.video_ratio,
v: req.voice_library_id,
b: req.bgm_config,
title: req.title_config,
})
}
export function useServerPreview({
enabled,
buildRequest,
onPreviewTaskCreated,
}: UseServerPreviewOptions): UseServerPreviewReturn {
const [status, setStatus] = useState<ServerPreviewStatus>("idle")
const [videoUrl, setVideoUrl] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const [progress, setProgress] = useState(0)
const [taskId, setTaskId] = useState<string | null>(null)
const pollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const timeoutTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const requestSeqRef = useRef(0)
const renderedFingerprintRef = useRef<string>("")
const mountedRef = useRef(true)
const networkRetriesRef = useRef(0)
// 始终持有最新的 buildRequest 和回调
const buildRequestRef = useRef(buildRequest)
buildRequestRef.current = buildRequest
const onCreatedRef = useRef(onPreviewTaskCreated)
onCreatedRef.current = onPreviewTaskCreated
/* ── 清理 ── */
const clearTimers = useCallback(() => {
if (pollTimerRef.current) {
clearTimeout(pollTimerRef.current)
pollTimerRef.current = null
}
if (timeoutTimerRef.current) {
clearTimeout(timeoutTimerRef.current)
timeoutTimerRef.current = null
}
}, [])
useEffect(() => {
mountedRef.current = true
return () => {
mountedRef.current = false
clearTimers()
}
}, [clearTimers])
/* ── 创建预览 + 轮询 ── */
const createAndPoll = useCallback(
async (request: CreatePreviewRequest, seq: number) => {
setStatus("loading")
setProgress(0)
setError(null)
networkRetriesRef.current = 0
try {
const resp = await createPreview(request)
if (seq !== requestSeqRef.current || !mountedRef.current) return
setTaskId(resp.task_id)
onCreatedRef.current?.(resp.task_id, resp.source_edit_plan_id)
let completed = false
// 超时保护
timeoutTimerRef.current = setTimeout(() => {
if (completed || seq !== requestSeqRef.current || !mountedRef.current) return
completed = true
clearTimers()
setStatus("failed")
setError("预览渲染超时(120秒),请重试")
}, POLL_TIMEOUT)
const poll = async () => {
if (completed || seq !== requestSeqRef.current || !mountedRef.current) return
try {
const st = await getPreviewStatus(resp.task_id)
if (completed || seq !== requestSeqRef.current || !mountedRef.current) return
if (st.status === "completed" && st.video_url) {
completed = true
clearTimers()
renderedFingerprintRef.current = buildFingerprint(request)
setVideoUrl(st.video_url)
setProgress(100)
setStatus("ready")
setError(null)
return
}
if (st.status === "failed" || st.status === "cancelled") {
completed = true
clearTimers()
setStatus("failed")
setError(
st.status === "cancelled"
? "预览任务已取消"
: st.error_message || "预览渲染失败,请重试",
)
return
}
// pending / generating
if (typeof st.progress === "number") setProgress(st.progress)
pollTimerRef.current = setTimeout(poll, POLL_INTERVAL)
} catch (pollErr) {
if (completed || seq !== requestSeqRef.current || !mountedRef.current) return
if (networkRetriesRef.current < MAX_NETWORK_RETRIES) {
networkRetriesRef.current += 1
console.warn(
`[ServerPreview] 轮询网络错误,第 ${networkRetriesRef.current} 次重试`,
pollErr,
)
pollTimerRef.current = setTimeout(poll, POLL_INTERVAL * 2)
} else {
completed = true
clearTimers()
setStatus("failed")
setError("网络错误,无法获取预览状态,请重试")
}
}
}
poll()
} catch (createErr) {
if (seq !== requestSeqRef.current || !mountedRef.current) return
console.error("[ServerPreview] 创建预览任务失败:", createErr)
const isNetworkError =
!!(createErr as { request?: unknown })?.request ||
(createErr as { code?: string })?.code === "ERR_NETWORK"
if (isNetworkError && networkRetriesRef.current < MAX_NETWORK_RETRIES) {
networkRetriesRef.current += 1
console.warn(`[ServerPreview] 创建任务网络错误,第 ${networkRetriesRef.current} 次重试`)
setTimeout(() => {
if (seq === requestSeqRef.current && mountedRef.current) {
createAndPoll(request, seq)
}
}, POLL_INTERVAL * 2)
return
}
const errData = (
createErr as { response?: { data?: { detail?: string; message?: string } } }
)?.response?.data
setStatus("failed")
setError(errData?.detail || errData?.message || "预览任务创建失败,请重试")
}
},
[clearTimers],
)
/* ── 手动触发预览 ── */
const triggerPreview = useCallback(() => {
if (!enabled) return
const request = buildRequestRef.current()
if (!request.template_id || request.asset_ids.length === 0) return
clearTimers()
const seq = ++requestSeqRef.current
setVideoUrl(null)
setTaskId(null)
createAndPoll(request, seq)
}, [enabled, clearTimers, createAndPoll])
/* ── 自动触发 + 配置变更检测 ── */
// 每次 render 都检查最新配置 fingerprint,与已渲染的 fingerprint 比较
const request = enabled ? buildRequest() : null
const currentFingerprint = request
? request.template_id && request.asset_ids.length > 0
? buildFingerprint(request)
: ""
: ""
// 首次进入自动触发
const didInitRef = useRef(false)
useEffect(() => {
if (!enabled || !currentFingerprint) {
didInitRef.current = false
// 禁用时取消进行中的轮询,避免回到前序步骤后仍在后台轮询
requestSeqRef.current += 1
clearTimers()
return
}
if (!didInitRef.current) {
didInitRef.current = true
renderedFingerprintRef.current = currentFingerprint
triggerPreview()
}
}, [enabled, currentFingerprint, triggerPreview, clearTimers])
// 配置变更检测:素材/配音/BGM 等变化 → 自动重渲染;标题样式变化 → 标记 stale
const prevFingerprintRef = useRef(currentFingerprint)
useEffect(() => {
if (!enabled || !currentFingerprint) return
const prev = prevFingerprintRef.current
prevFingerprintRef.current = currentFingerprint
if (!prev || prev === currentFingerprint) return
if (currentFingerprint === renderedFingerprintRef.current) return
// 配置已变更
// 判断是标题样式变更还是素材/配音/BGM 变更
const prevParsed = JSON.parse(prev) as Record<string, unknown>
const currParsed = JSON.parse(currentFingerprint) as Record<string, unknown>
const nonTitleChanged =
prevParsed.t !== currParsed.t ||
prevParsed.a !== currParsed.a ||
prevParsed.d !== currParsed.d ||
prevParsed.r !== currParsed.r ||
prevParsed.v !== currParsed.v ||
JSON.stringify(prevParsed.b) !== JSON.stringify(currParsed.b)
if (nonTitleChanged) {
// 素材/配音/BGM/模板等变化 → 自动重新渲染
renderedFingerprintRef.current = currentFingerprint
triggerPreview()
} else {
// 仅标题文字/样式变化 → 标记 stale,不自动重渲染(避免频繁请求)
// 实时预览由 CSS TitleOverlay 提供
setStatus((s) => (s === "ready" ? "stale" : s))
}
}, [enabled, currentFingerprint, triggerPreview])
return {
status,
videoUrl,
error,
progress,
triggerPreview,
taskId,
}
}
export default useServerPreview
@@ -3,7 +3,7 @@
* 管理步骤切换与各步骤的前置校验
* 步骤顺序:模板(1) → 素材(2) → 配音(3) → 标题(4) → 预览(5) → 封面(6) → 确认(7)
*
* V24: previewReady 改为前端素材加载状态
* 服务器预览架构:Step5 需要服务器渲染预览完成才能前进
*/
import { message } from "antd"
import type { TitleSettings } from "../types"
@@ -16,7 +16,7 @@ export interface UseStepNavigationOptions {
selectedMaterials: string[]
smartSelectedIds: string[]
titleSettings: TitleSettings
/** 预览是否就绪(前端素材已加载 */
/** 服务器预览是否已完成(ready 状态 */
previewReady: boolean
}
@@ -50,13 +50,12 @@ export const useStepNavigation = (options: UseStepNavigationOptions): UseStepNav
message.warning("请先进行智能匹配并选择素材")
return
}
// Step3 配音:配音为可选项,不强制校验,用户可跳过
if (currentStep === 4 && !titleSettings.title.trim()) {
message.warning("请选择或输入标题")
return
}
if (currentStep === 5 && !previewReady) {
message.warning("请先选择素材以预览效果")
message.warning("请等待预览视频渲染完成后再继续")
return
}
if (currentStep < 7) {