feat(#1197): Phase1 预览生成前端改造 #1206
@@ -0,0 +1,2 @@
|
||||
allowBuilds:
|
||||
esbuild: set this to true or false
|
||||
@@ -0,0 +1,8 @@
|
||||
export type {
|
||||
PreviewStatus,
|
||||
CreatePreviewRequest,
|
||||
CreatePreviewResponse,
|
||||
PreviewTaskResponse,
|
||||
} from "./types"
|
||||
|
||||
export { createPreview, getPreviewStatus } from "./preview"
|
||||
@@ -0,0 +1,16 @@
|
||||
import apiClient from "../client"
|
||||
import type { CreatePreviewRequest, CreatePreviewResponse, PreviewTaskResponse } from "./types"
|
||||
|
||||
/** 创建预览生成任务(单版本) */
|
||||
export const createPreview = async (
|
||||
params: CreatePreviewRequest,
|
||||
): Promise<CreatePreviewResponse> => {
|
||||
const response = await apiClient.post<CreatePreviewResponse>("/generation/preview", params)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 查询预览任务状态及结果 */
|
||||
export const getPreviewStatus = async (taskId: string): Promise<PreviewTaskResponse> => {
|
||||
const response = await apiClient.get<PreviewTaskResponse>(`/generation/preview/${taskId}`)
|
||||
return response.data
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/** 预览任务状态 */
|
||||
export type PreviewStatus = "pending" | "generating" | "completed" | "failed" | "cancelled"
|
||||
|
||||
/** 创建预览任务请求 */
|
||||
export interface CreatePreviewRequest {
|
||||
template_id: string
|
||||
asset_ids: string[]
|
||||
title_ids?: string[]
|
||||
voice_ids?: string[]
|
||||
video_title?: string
|
||||
duration?: number
|
||||
video_ratio?: string
|
||||
bgm_config?: {
|
||||
enabled: boolean
|
||||
preset_id?: string
|
||||
volume?: number
|
||||
}
|
||||
}
|
||||
|
||||
/** 创建预览任务响应 */
|
||||
export interface CreatePreviewResponse {
|
||||
task_id: string
|
||||
status: PreviewStatus
|
||||
is_preview: boolean
|
||||
resolution: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
/** 预览任务详情响应 */
|
||||
export interface PreviewTaskResponse {
|
||||
task_id: string
|
||||
status: PreviewStatus
|
||||
progress: number
|
||||
is_preview: boolean
|
||||
resolution: string
|
||||
video_url?: string
|
||||
duration?: number
|
||||
file_size?: number
|
||||
clip_count?: number
|
||||
transition_count?: number
|
||||
material_usage?: number
|
||||
error_message?: string
|
||||
created_at: string
|
||||
started_at?: string
|
||||
finished_at?: string
|
||||
generate_duration?: number
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import CloneModal from "@/components/voice/CloneModal"
|
||||
import GenerateHeader from "./components/GenerateHeader"
|
||||
import GenerateStepsBar from "./components/GenerateStepsBar"
|
||||
import GenerateResultPanel from "./components/GenerateResultPanel"
|
||||
import PreviewVideoPanel from "./components/PreviewVideoPanel"
|
||||
import GenerateStepContent from "./components/GenerateStepContent"
|
||||
import GenerateStepActions from "./components/GenerateStepActions"
|
||||
import { useGenerateFormState } from "./hooks/useGenerateFormState"
|
||||
@@ -191,6 +192,7 @@ const GeneratePage: React.FC = () => {
|
||||
previewError={step3Preview.previewError}
|
||||
previewTemplateName={step3Preview.templateName}
|
||||
previewMaterialCount={step3Preview.materialCount}
|
||||
previewProgress={step3Preview.progress}
|
||||
onGeneratePreview={step3Preview.generatePreview}
|
||||
onRegeneratePreview={step3Preview.regeneratePreview}
|
||||
/>
|
||||
@@ -206,21 +208,36 @@ const GeneratePage: React.FC = () => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ════ 右侧:生成结果 ════ */}
|
||||
<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 className="xx-generate-right-col">
|
||||
{/* 预览视频面板(Step3+ 常驻) */}
|
||||
{currentStep >= 3 && (
|
||||
<PreviewVideoPanel
|
||||
previewStatus={step3Preview.previewStatus}
|
||||
previewResult={step3Preview.previewResult}
|
||||
previewError={step3Preview.previewError}
|
||||
progress={step3Preview.progress}
|
||||
videoRatio={videoRatio}
|
||||
onRegenerate={step3Preview.regeneratePreview}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 正式生成结果 */}
|
||||
<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>
|
||||
|
||||
{/* ── 视频预览弹窗 ── */}
|
||||
|
||||
@@ -69,6 +69,7 @@ export interface GenerateStepContentProps {
|
||||
previewError: string
|
||||
previewTemplateName: string
|
||||
previewMaterialCount: string
|
||||
previewProgress: number
|
||||
onGeneratePreview: () => void
|
||||
onRegeneratePreview: () => void
|
||||
}
|
||||
@@ -117,6 +118,7 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
|
||||
previewError,
|
||||
previewTemplateName,
|
||||
previewMaterialCount,
|
||||
previewProgress,
|
||||
onGeneratePreview,
|
||||
onRegeneratePreview,
|
||||
} = props
|
||||
@@ -151,6 +153,7 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
|
||||
previewStatus={previewStatus}
|
||||
previewResult={previewResult}
|
||||
previewError={previewError}
|
||||
progress={previewProgress}
|
||||
onGeneratePreview={onGeneratePreview}
|
||||
onRegeneratePreview={onRegeneratePreview}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* 右侧预览视频面板
|
||||
* Step3 生成预览后常驻显示预览视频
|
||||
*/
|
||||
import React from "react"
|
||||
import { PlayCircleOutlined, LoadingOutlined } from "@ant-design/icons"
|
||||
import type { PreviewResult, PreviewStatus } from "../hooks/useStep3Preview"
|
||||
|
||||
interface PreviewVideoPanelProps {
|
||||
previewStatus: PreviewStatus
|
||||
previewResult: PreviewResult | null
|
||||
previewError: string
|
||||
progress: number
|
||||
videoRatio: string
|
||||
onRegenerate: () => void
|
||||
}
|
||||
|
||||
export const PreviewVideoPanel: React.FC<PreviewVideoPanelProps> = ({
|
||||
previewStatus,
|
||||
previewResult,
|
||||
previewError,
|
||||
progress,
|
||||
videoRatio,
|
||||
onRegenerate,
|
||||
}) => {
|
||||
const hasPreview = previewStatus === "ready" && previewResult
|
||||
const isLoading = previewStatus === "pending" || previewStatus === "generating"
|
||||
const isError = previewStatus === "error"
|
||||
|
||||
return (
|
||||
<div className="xx-generate-preview">
|
||||
<div className="xx-preview-header">
|
||||
<h3>预览视频</h3>
|
||||
{hasPreview && <span className="xx-preview-badge">480p 预览版</span>}
|
||||
</div>
|
||||
|
||||
{/* 空状态:还没生成预览 */}
|
||||
{previewStatus === "idle" && (
|
||||
<div className="xx-preview-empty">
|
||||
<PlayCircleOutlined
|
||||
style={{ fontSize: 48, color: "var(--text-tertiary)", marginBottom: 12 }}
|
||||
/>
|
||||
<p className="xx-preview-empty-title">暂无预览</p>
|
||||
<p className="xx-preview-empty-desc">在第 3 步生成预览后在此查看</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 生成中 */}
|
||||
{isLoading && (
|
||||
<div className="xx-preview-loading-panel">
|
||||
<div className="xx-preview-video">
|
||||
<div className="xx-preview-loading-center">
|
||||
<LoadingOutlined style={{ fontSize: 36, color: "#fff" }} spin />
|
||||
<p style={{ marginTop: 12, color: "rgba(255,255,255,0.8)", fontSize: 14 }}>
|
||||
{previewStatus === "pending" ? "排队中..." : `生成中 ${progress}%`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="xx-preview-progress-bar-wrap">
|
||||
<div className="xx-preview-progress-fill" style={{ width: `${progress}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 生成失败 */}
|
||||
{isError && (
|
||||
<div className="xx-preview-error-panel">
|
||||
<div className="xx-preview-video xx-preview-video--error">
|
||||
<p style={{ color: "rgba(255,255,255,0.8)", fontSize: 14 }}>预览生成失败</p>
|
||||
</div>
|
||||
<p className="xx-preview-error-msg">{previewError || "请重试"}</p>
|
||||
<button className="xx-btn xx-btn-ghost xx-btn-block" onClick={onRegenerate}>
|
||||
重新生成
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 预览成功 */}
|
||||
{hasPreview && (
|
||||
<div className="xx-preview-video">
|
||||
<video src={previewResult.videoUrl} controls preload="metadata" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 预览信息 */}
|
||||
{hasPreview && previewResult && (
|
||||
<div className="xx-preview-info">
|
||||
<div className="xx-preview-info-row">
|
||||
<span>时长</span>
|
||||
<span>{previewResult.duration.toFixed(1)} 秒</span>
|
||||
</div>
|
||||
<div className="xx-preview-info-row">
|
||||
<span>片段数</span>
|
||||
<span>{previewResult.clipCount} 段</span>
|
||||
</div>
|
||||
<div className="xx-preview-info-row">
|
||||
<span>比例</span>
|
||||
<span>{videoRatio}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PreviewVideoPanel
|
||||
@@ -1,7 +1,6 @@
|
||||
/**
|
||||
* Step 3 生成预览组件
|
||||
* 展示剪辑方案预览,用户确认后进入下一步
|
||||
* TODO: 后端预览接口对齐后,替换 mock 预览为真实视频/方案预览
|
||||
* 调用后端预览生成接口,展示真实视频预览
|
||||
*/
|
||||
import React from "react"
|
||||
import {
|
||||
@@ -10,6 +9,7 @@ import {
|
||||
ReloadOutlined,
|
||||
PlayCircleOutlined,
|
||||
ExclamationCircleFilled,
|
||||
ClockCircleOutlined,
|
||||
} from "@ant-design/icons"
|
||||
import type { PreviewResult, PreviewStatus } from "../hooks/useStep3Preview"
|
||||
|
||||
@@ -21,6 +21,7 @@ interface Step3GeneratePreviewProps {
|
||||
previewStatus: PreviewStatus
|
||||
previewResult: PreviewResult | null
|
||||
previewError: string
|
||||
progress: number
|
||||
onGeneratePreview: () => void
|
||||
onRegeneratePreview: () => void
|
||||
}
|
||||
@@ -28,11 +29,11 @@ interface Step3GeneratePreviewProps {
|
||||
const Step3GeneratePreview: React.FC<Step3GeneratePreviewProps> = ({
|
||||
templateName,
|
||||
materialCount,
|
||||
duration,
|
||||
videoRatio,
|
||||
previewStatus,
|
||||
previewResult,
|
||||
previewError,
|
||||
progress,
|
||||
onGeneratePreview,
|
||||
onRegeneratePreview,
|
||||
}) => {
|
||||
@@ -47,7 +48,7 @@ const Step3GeneratePreview: React.FC<Step3GeneratePreviewProps> = ({
|
||||
<PlayCircleOutlined style={{ fontSize: 32, color: "#3b82f6", marginBottom: 12 }} />
|
||||
<p className="xx-preview-generate-title">一键生成剪辑预览</p>
|
||||
<p className="xx-preview-generate-desc">
|
||||
AI 将根据您选择的模板和素材,智能生成剪辑方案预览
|
||||
AI 将根据您选择的模板和素材,智能生成完整视频预览(480p 低清版)
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
@@ -59,12 +60,24 @@ const Step3GeneratePreview: React.FC<Step3GeneratePreviewProps> = ({
|
||||
</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">正在生成剪辑预览...</p>
|
||||
<p className="xx-preview-loading-desc">AI 正在分析素材并匹配剪辑方案</p>
|
||||
<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>
|
||||
)}
|
||||
|
||||
@@ -80,12 +93,12 @@ const Step3GeneratePreview: React.FC<Step3GeneratePreviewProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 生成成功 - 预览卡片 */}
|
||||
{/* 生成成功 - 视频预览 */}
|
||||
{previewStatus === "ready" && previewResult && (
|
||||
<>
|
||||
<div className="xx-preview-tip">
|
||||
<CheckCircleFilled style={{ color: "#52c41a", marginRight: 8 }} />
|
||||
<span>预览生成成功,确认无误后进入下一步</span>
|
||||
<span>预览生成成功,确认效果后进入下一步</span>
|
||||
<button
|
||||
className="xx-preview-regenerate-btn"
|
||||
onClick={onRegeneratePreview}
|
||||
@@ -95,8 +108,19 @@ const Step3GeneratePreview: React.FC<Step3GeneratePreviewProps> = ({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 视频播放器 */}
|
||||
<div className="xx-preview-video-wrapper">
|
||||
<video
|
||||
className="xx-preview-video"
|
||||
src={previewResult.videoUrl}
|
||||
controls
|
||||
preload="metadata"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 剪辑方案信息 */}
|
||||
<div className="xx-preview-plan-card">
|
||||
<div className="xx-preview-plan-title">剪辑方案预览</div>
|
||||
<div className="xx-preview-plan-title">剪辑方案信息</div>
|
||||
<div className="xx-preview-plan-info">
|
||||
<div className="xx-preview-plan-row">
|
||||
<span className="xx-preview-plan-label">模板</span>
|
||||
@@ -107,7 +131,7 @@ const Step3GeneratePreview: React.FC<Step3GeneratePreviewProps> = ({
|
||||
<span className="xx-preview-plan-value">{materialCount}</span>
|
||||
</div>
|
||||
<div className="xx-preview-plan-row">
|
||||
<span className="xx-preview-plan-label">预计片段数</span>
|
||||
<span className="xx-preview-plan-label">片段数</span>
|
||||
<span className="xx-preview-plan-value">{previewResult.clipCount} 段</span>
|
||||
</div>
|
||||
<div className="xx-preview-plan-row">
|
||||
@@ -119,17 +143,17 @@ const Step3GeneratePreview: React.FC<Step3GeneratePreviewProps> = ({
|
||||
<span className="xx-preview-plan-value">{previewResult.materialUsage}%</span>
|
||||
</div>
|
||||
<div className="xx-preview-plan-row">
|
||||
<span className="xx-preview-plan-label">预计时长</span>
|
||||
<span className="xx-preview-plan-value">{duration} 秒</span>
|
||||
<span className="xx-preview-plan-label">实际时长</span>
|
||||
<span className="xx-preview-plan-value">
|
||||
{previewResult.duration.toFixed(1)} 秒
|
||||
</span>
|
||||
</div>
|
||||
<div className="xx-preview-plan-row">
|
||||
<span className="xx-preview-plan-label">视频比例</span>
|
||||
<span className="xx-preview-plan-value">{videoRatio}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="xx-preview-plan-hint">
|
||||
💡 点击「下一步」进入标题设置,AI 将根据素材内容为您推荐标题
|
||||
</div>
|
||||
<div className="xx-preview-plan-hint">💡 这是 480p 预览版,正式生成将输出高清视频</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -2544,6 +2544,146 @@
|
||||
生成结果(右侧)
|
||||
================================================================ */
|
||||
|
||||
.xx-generate-right-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.xx-preview-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.xx-preview-header h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.xx-preview-badge {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
background: var(--bg-tertiary);
|
||||
padding: 2px 8px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.xx-preview-empty {
|
||||
text-align: center;
|
||||
padding: 40px 0;
|
||||
}
|
||||
|
||||
.xx-preview-empty-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
margin: 0 0 4px 0;
|
||||
}
|
||||
|
||||
.xx-preview-empty-desc {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.xx-preview-loading-panel {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.xx-preview-loading-center {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.xx-preview-progress-bar-wrap {
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 2px;
|
||||
margin-top: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.xx-preview-progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #3b82f6, #8b5cf6);
|
||||
border-radius: 2px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.xx-preview-error-panel {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.xx-preview-video--error {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.xx-preview-error-msg {
|
||||
font-size: 13px;
|
||||
color: #ef4444;
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
.xx-preview-info {
|
||||
margin-top: 16px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.xx-preview-info-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.xx-preview-info-row span:last-child {
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Step3 内部进度条 */
|
||||
.xx-preview-loading .xx-preview-progress-bar {
|
||||
width: 200px;
|
||||
height: 6px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 3px;
|
||||
margin: 12px auto 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.xx-preview-loading .xx-preview-progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #3b82f6, #8b5cf6);
|
||||
border-radius: 3px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.xx-preview-video-wrapper {
|
||||
margin: 16px 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.xx-preview-video-wrapper .xx-preview-video {
|
||||
max-width: 300px;
|
||||
width: 100%;
|
||||
aspect-ratio: 9 / 16;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.xx-generate-result {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--radius-xl);
|
||||
@@ -2762,4 +2902,3 @@
|
||||
.ant-modal-close {
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
|
||||
@@ -137,7 +137,7 @@ export const useGenerateFormState = (): GenerateFormState => {
|
||||
const [generateCount, setGenerateCount] = useState(1)
|
||||
|
||||
/* ── 高级设置(隐藏但保留) ── */
|
||||
const [videoRatio] = useState("16:9")
|
||||
const [videoRatio] = useState("9:16")
|
||||
const [duration] = useState(30)
|
||||
const [style] = useState("business")
|
||||
const [autoSubtitles] = useState(true)
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
/**
|
||||
* Step 3 生成预览 Hook
|
||||
* 封装预览信息计算 + 预览生成逻辑
|
||||
* TODO: 后端预览接口对齐后,替换 mock 逻辑为真实 API 调用
|
||||
* 调用 /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"
|
||||
|
||||
interface UseStep3PreviewProps {
|
||||
@@ -14,24 +15,27 @@ interface UseStep3PreviewProps {
|
||||
smartSelectedIds: string[]
|
||||
duration: number
|
||||
videoRatio: string
|
||||
titleText?: string
|
||||
voiceId?: string
|
||||
}
|
||||
|
||||
export type PreviewStatus = "idle" | "generating" | "ready" | "error"
|
||||
export type PreviewStatus = "idle" | "pending" | "generating" | "ready" | "error"
|
||||
|
||||
/** 预览生成结果 */
|
||||
export interface PreviewResult {
|
||||
/** 预计片段数 */
|
||||
taskId: string
|
||||
videoUrl: string
|
||||
clipCount: number
|
||||
/** 预计转场数 */
|
||||
transitionCount: number
|
||||
/** 素材使用率 */
|
||||
materialUsage: number
|
||||
/** 预览视频URL(后端接口出来后填充) */
|
||||
previewVideoUrl: string | null
|
||||
/** 生成耗时(秒) */
|
||||
duration: number
|
||||
fileSize: number
|
||||
generateDuration: number
|
||||
progress: number
|
||||
}
|
||||
|
||||
// 轮询超时时间(10 分钟)
|
||||
const POLL_TIMEOUT_MS = 10 * 60 * 1000
|
||||
export function useStep3Preview({
|
||||
templates,
|
||||
selectedTemplate,
|
||||
@@ -59,11 +63,21 @@ export function useStep3Preview({
|
||||
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 requestIdRef = useRef(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,
|
||||
@@ -93,11 +107,13 @@ export function useStep3Preview({
|
||||
].join("|")
|
||||
|
||||
if (prevKey !== currentKey && previewStatus !== "idle") {
|
||||
// 参数变化时递增请求 ID,作废之前进行中的预览生成
|
||||
requestIdRef.current += 1
|
||||
// 参数变化,作废当前任务
|
||||
currentTaskIdRef.current = null
|
||||
clearPollTimer()
|
||||
setPreviewStatus("idle")
|
||||
setPreviewResult(null)
|
||||
setPreviewError("")
|
||||
setProgress(0)
|
||||
}
|
||||
|
||||
prevDepsRef.current = {
|
||||
@@ -116,15 +132,86 @@ export function useStep3Preview({
|
||||
duration,
|
||||
videoRatio,
|
||||
previewStatus,
|
||||
clearPollTimer,
|
||||
])
|
||||
|
||||
/**
|
||||
* 生成预览
|
||||
* TODO: 后端 /generation-preview 接口对齐后替换此处 mock 逻辑
|
||||
* 预计接口:POST /generate-preview
|
||||
* 入参:template_id, material_ids, duration, video_ratio, style
|
||||
* 出参:preview_id, status, clip_count, preview_video_url
|
||||
*/
|
||||
// 组件卸载时清理轮询
|
||||
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: data.task_id,
|
||||
videoUrl: data.video_url ?? "",
|
||||
clipCount: data.clip_count ?? 0,
|
||||
transitionCount: data.transition_count ?? 0,
|
||||
materialUsage: data.material_usage ?? 0,
|
||||
duration: data.duration ?? 0,
|
||||
fileSize: data.file_size ?? 0,
|
||||
generateDuration: data.generate_duration ?? 0,
|
||||
progress: 100,
|
||||
})
|
||||
setPreviewStatus("ready")
|
||||
return
|
||||
}
|
||||
|
||||
if (status === "failed") {
|
||||
setPreviewError(data.error_message || "预览生成失败,请重试")
|
||||
setPreviewStatus("error")
|
||||
return
|
||||
}
|
||||
|
||||
if (status === "cancelled") {
|
||||
setPreviewError("预览任务已取消")
|
||||
setPreviewStatus("error")
|
||||
return
|
||||
}
|
||||
|
||||
// pending / generating 状态继续轮询
|
||||
setProgress(data.progress || 0)
|
||||
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("请先选择模板")
|
||||
@@ -137,42 +224,44 @@ export function useStep3Preview({
|
||||
return
|
||||
}
|
||||
|
||||
// 生成新的请求 ID,用于竞态检查
|
||||
const requestId = requestIdRef.current + 1
|
||||
requestIdRef.current = requestId
|
||||
// 取消之前的轮询
|
||||
clearPollTimer()
|
||||
|
||||
setPreviewStatus("generating")
|
||||
setPreviewStatus("pending")
|
||||
setPreviewError("")
|
||||
setProgress(0)
|
||||
setPreviewResult(null)
|
||||
startTimeRef.current = Date.now()
|
||||
|
||||
try {
|
||||
// TODO: 替换为真实后端接口调用
|
||||
// const res = await generatePreviewAPI({ templateId: selectedTemplate, ... })
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500))
|
||||
const assetIds = materialMode === "auto" ? smartSelectedIds : selectedMaterials
|
||||
const response = await createPreview({
|
||||
template_id: selectedTemplate,
|
||||
asset_ids: assetIds,
|
||||
duration: duration || undefined,
|
||||
video_ratio: videoRatio,
|
||||
})
|
||||
|
||||
// 竞态检查:如果请求已被新请求或参数变化作废,则丢弃结果
|
||||
if (requestIdRef.current !== requestId) return
|
||||
// 竞态检查
|
||||
if (startTimeRef.current === 0) return // 已被重置
|
||||
|
||||
// Mock 结果
|
||||
const estimatedClips = Math.max(3, Math.min(materialTotal * 2, 20))
|
||||
const result: PreviewResult = {
|
||||
clipCount: estimatedClips,
|
||||
transitionCount: estimatedClips - 1,
|
||||
materialUsage: Math.min(
|
||||
100,
|
||||
Math.round((materialTotal / Math.max(estimatedClips, 1)) * 100),
|
||||
),
|
||||
previewVideoUrl: null, // 后端接口出来后填充
|
||||
generateDuration: 1.5,
|
||||
}
|
||||
|
||||
setPreviewResult(result)
|
||||
setPreviewStatus("ready")
|
||||
currentTaskIdRef.current = response.task_id
|
||||
pollPreviewStatus(response.task_id)
|
||||
} catch (e) {
|
||||
if (requestIdRef.current !== requestId) return
|
||||
setPreviewError(e instanceof Error ? e.message : "预览生成失败")
|
||||
setPreviewStatus("error")
|
||||
}
|
||||
}, [selectedTemplate, materialTotal])
|
||||
}, [
|
||||
selectedTemplate,
|
||||
materialTotal,
|
||||
materialMode,
|
||||
smartSelectedIds,
|
||||
selectedMaterials,
|
||||
duration,
|
||||
videoRatio,
|
||||
clearPollTimer,
|
||||
pollPreviewStatus,
|
||||
])
|
||||
|
||||
/** 重新生成预览 */
|
||||
const regeneratePreview = useCallback(() => {
|
||||
@@ -191,6 +280,7 @@ export function useStep3Preview({
|
||||
previewStatus,
|
||||
previewResult,
|
||||
previewError,
|
||||
progress,
|
||||
canProceed,
|
||||
generatePreview,
|
||||
regeneratePreview,
|
||||
|
||||
@@ -5,14 +5,24 @@
|
||||
*
|
||||
* 重构记录:
|
||||
* - useStep2Materials 拆分为 useMaterialLibrary + useSmartMatch 子 Hook
|
||||
* - Phase1 预览生成:新增 generation API 层 + PreviewVideoPanel
|
||||
*/
|
||||
import { describe, it, expect } from "vitest"
|
||||
|
||||
// API 层 - 预览生成
|
||||
import "@/api/generation"
|
||||
import "@/api/generation/preview"
|
||||
import "@/api/generation/types"
|
||||
|
||||
// 直接引入所有 Step 组件,建立完整依赖链
|
||||
import "@/pages/generate/GeneratePage"
|
||||
import "@/pages/generate/components/Step2MaterialSelect"
|
||||
import "@/pages/generate/components/Step3GeneratePreview"
|
||||
import "@/pages/generate/components/Step4TitleSettings"
|
||||
import "@/pages/generate/components/Step5VoiceSelect"
|
||||
import "@/pages/generate/components/PreviewVideoPanel"
|
||||
import "@/pages/generate/components/GenerateResultPanel"
|
||||
import "@/pages/generate/components/GenerateStepContent"
|
||||
import "@/pages/generate/components/voice/VoiceRecommendSection"
|
||||
import "@/pages/generate/components/voice/VoiceChoiceCard"
|
||||
import "@/pages/generate/components/voice/PresetVoiceDetail"
|
||||
@@ -37,6 +47,7 @@ describe("GeneratePage module smoke test", () => {
|
||||
})
|
||||
})
|
||||
import "@/pages/generate/hooks/useGenerateVideo"
|
||||
import "@/pages/generate/hooks/useStep3Preview"
|
||||
import "@/pages/generate/hooks/generate-video/useGenerationPolling"
|
||||
import "@/pages/generate/hooks/useGenerateFormState"
|
||||
import "@/pages/generate/hooks/useGenerateFormState/useTemplateSelection"
|
||||
|
||||
Reference in New Issue
Block a user