🖼️ 选择封面
+ {/* 最终成片信息 */}
+ {finalVideo && (
+
+ 🎬 封面将从最终成片「{finalVideo.name}」中智能选帧
+
+ )}
+
-
diff --git a/apps/web/src/pages/generate/constants.ts b/apps/web/src/pages/generate/constants.ts
index c6a9bf764..e13061fdd 100644
--- a/apps/web/src/pages/generate/constants.ts
+++ b/apps/web/src/pages/generate/constants.ts
@@ -33,8 +33,8 @@ export const STEPS = [
{ key: 2, label: "选择素材" },
{ key: 3, label: "选择配音" },
{ key: 4, label: "选择标题" },
- { key: 5, label: "选择封面" },
- { key: 6, label: "确认生成" },
+ { key: 5, label: "确认生成" },
+ { key: 6, label: "选择封面" },
]
/* ── 标题位置选项 ── */
diff --git a/apps/web/src/pages/generate/hooks/useStep6Cover.ts b/apps/web/src/pages/generate/hooks/useStep6Cover.ts
index 79fba4fc9..cf3bda235 100644
--- a/apps/web/src/pages/generate/hooks/useStep6Cover.ts
+++ b/apps/web/src/pages/generate/hooks/useStep6Cover.ts
@@ -1,13 +1,13 @@
/**
* Step 6 封面设置 Hook
- * 封装封面设置的交互逻辑,对接后端封面模板 CRUD API
+ * 封面候选帧从确认生成的最终视频中获取(MediaKit 选帧)
+ * 不再从预览片段创建预览视频
*/
-import { useCallback, useEffect, useRef, useState } from "react"
+import { useCallback, useEffect, useState } from "react"
import { message } from "antd"
import type { CoverConfig, CoverTemplate } from "../types/cover"
import { generateCover } from "@/api/generation"
-import { createPreview, getPreviewStatus } from "@/api/generation/preview"
-import { updateEditPlan } from "@/api/template-editor"
+import type { GeneratedVideo } from "@/api/template-editor"
import type { TitleSettings } from "../types"
import {
fetchCoverTemplates,
@@ -19,47 +19,22 @@ import {
interface UseStep6CoverProps {
coverSettings: CoverConfig
onCoverSettingsChange: (settings: CoverConfig) => void
- duration: number
- /** 当前素材 ID 列表,用于智能封面生成 */
- assetIds?: string[]
/** 当前选中的模板 ID */
selectedTemplate?: string
- /** Step4 标题设置,用于预览视频烧录标题 & 封面叠加标题 */
+ /** Step4 标题设置,用于封面叠加标题 */
titleSettings?: TitleSettings
- /** 预览任务创建回调——将 task_id 暴露给父组件供 confirmGeneration 复用 */
- onPreviewTaskCreated?: (taskId: string) => void
- /** 从预览响应中提取到 source_edit_plan_id 时的回调 */
- onSourceEditPlanIdExtracted?: (planId: string) => void
- /** 配音模式 */
- voiceMode?: "preset" | "custom" | "clone"
- /** 选中的配音素材 ID(配音素材库 asset ID) */
- selectedVoice?: string
- /** 选中的克隆音色 ID */
- selectedClonedVoice?: string
- /** BGM 开关 */
- bgm?: boolean
- /** BGM 配置(来自模板) */
- bgmConfig?: { enabled: boolean; music_id?: string }
+ /** 确认生成步骤产出的最终视频列表 */
+ generatedVideos: GeneratedVideo[]
}
export function useStep6Cover({
coverSettings,
onCoverSettingsChange,
- duration,
- assetIds = [],
selectedTemplate = "",
titleSettings,
- onPreviewTaskCreated,
- onSourceEditPlanIdExtracted,
- voiceMode,
- selectedVoice,
- selectedClonedVoice,
- bgm,
- bgmConfig,
+ generatedVideos,
}: UseStep6CoverProps) {
const [generating, setGenerating] = useState(false)
- // 防竞态:记录当前预览生成的参数指纹,任务完成时校验一致性
- const previewParamsRef = useRef
("")
// ── 封面设置弹窗状态 ──
const [showCoverSettings, setShowCoverSettings] = useState(false)
@@ -72,6 +47,9 @@ export function useStep6Cover({
const [templatesLoading, setTemplatesLoading] = useState(false)
const [templatesError, setTemplatesError] = useState(null)
+ /** 最终成片:取第一个已完成视频 */
+ const finalVideo = generatedVideos.find((v) => v.status === "completed") || generatedVideos[0]
+
/** 从后端加载封面模板列表 */
const loadTemplates = useCallback(async () => {
setTemplatesLoading(true)
@@ -94,7 +72,7 @@ export function useStep6Cover({
}
}, [showCoverSettings, loadTemplates])
- /** 调用后端智能封面 API,生成封面并更新预览 */
+ /** 调用后端智能封面 API,从最终成片中抽帧 */
const generateAutoCover = useCallback(async () => {
if (generating) {
message.warning("封面正在生成中,请稍候...")
@@ -106,19 +84,20 @@ export function useStep6Cover({
return
}
- if (assetIds.length === 0) {
- message.error("请先选择素材")
+ if (!finalVideo) {
+ message.error("请先生成视频再选择封面")
return
}
setGenerating(true)
- // 超时保护:300 秒后强制重置,防止 state 卡死导致按钮永久失效
const timeoutId = setTimeout(() => {
setGenerating(false)
}, 300000)
+
try {
const response = await generateCover(selectedTemplate, {
- asset_ids: assetIds,
+ generated_video_id: finalVideo.id,
+ video_url: finalVideo.file_url || finalVideo.download_url || "",
cover_type: "ai_frame",
...(titleSettings?.title
? {
@@ -151,151 +130,9 @@ export function useStep6Cover({
clearTimeout(timeoutId)
console.error("[Step6] 智能封面生成失败:", err)
- // 提取详细错误信息
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const anyErr = err as any
- const statusCode = anyErr?.response?.status
-
- // 400 错误:精确判断是否为"预览缺失",避免误判其他 400 错误
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- const errCode = anyErr?.response?.data?.code as string | undefined
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- const errMsg = (anyErr?.response?.data?.message ||
- anyErr?.response?.data?.detail ||
- "") as string
- const isPreviewMissing =
- statusCode === 400 &&
- (errCode?.includes("PREVIEW") ||
- /预览.*(?:缺失|不存在|未找到)|(?:missing|not found|does not exist).*preview/i.test(
- errMsg,
- ))
-
- if (isPreviewMissing) {
- console.log("[Step6] 检测到预览缺失,尝试自动创建预览渲染任务...")
- message.info("正在准备预览视频,请稍候...")
- try {
- // 记录当前参数指纹,用于任务完成时校验一致性(防竞态)
- previewParamsRef.current = JSON.stringify({ selectedTemplate, assetIds, titleSettings })
- // 解析配音参数:voiceMode=clone 时用 selectedClonedVoice,否则用 selectedVoice
- const previewVoiceLibraryId =
- voiceMode === "clone" ? selectedClonedVoice || selectedVoice || "" : selectedVoice || ""
- const previewResp = await createPreview({
- template_id: selectedTemplate,
- asset_ids: assetIds,
- duration: duration || 30,
- // 配音:始终传递 voice_library_id,确保后端能正确接收
- voice_library_id: previewVoiceLibraryId,
- // 兜底:如果 voice_library_id 为空但 selectedVoice 有值,也传 voice_ids
- ...(selectedVoice && !previewVoiceLibraryId ? { voice_ids: [selectedVoice] } : {}),
- // BGM 配置:受 bgm 开关控制
- 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,
- },
- }
- : {}),
- })
- // 将预览任务 ID 暴露给父组件,供 Step7 确认生成时复用(confirmGeneration)
- const currentFingerprint = JSON.stringify({ selectedTemplate, assetIds, titleSettings })
- if (previewResp.task_id && previewParamsRef.current === currentFingerprint) {
- onPreviewTaskCreated?.(previewResp.task_id)
- // 提取后端自动关联的 source_edit_plan_id,供 fallback 路径使用
- if (previewResp.source_edit_plan_id) {
- onSourceEditPlanIdExtracted?.(previewResp.source_edit_plan_id)
- }
- }
- // 轮询等待预览渲染完成:递归 setTimeout 避免请求重叠 + 120s 超时兜底
- await new Promise((resolve, reject) => {
- let finished = false
- const done = (fn: () => void) => {
- if (finished) return
- finished = true
- clearTimeout(timeoutId)
- fn()
- }
- const timeoutId = setTimeout(() => {
- done(() => reject(new Error("预览生成超时,请稍后重试")))
- }, 120_000)
- const poll = async () => {
- if (finished) return
- try {
- const status = await getPreviewStatus(previewResp.task_id)
- if (status.status === "completed") {
- // 保存预览视频地址到 plan.config.rendered_storage_key,
- // 供封面 API 的 E1 兜底路径定位渲染后的视频(含标题烧录)。
- // video_url 可能是完整 http(s) URL 或 OSS storage_key,两种格式后端都能处理。
- if (status.video_url) {
- try {
- await updateEditPlan(selectedTemplate, {
- config: { rendered_storage_key: status.video_url },
- })
- } catch (saveErr) {
- console.warn(
- "[Step6] 保存 rendered_storage_key 失败(不阻塞封面重试):",
- saveErr,
- )
- }
- }
- done(() => resolve())
- } else if (status.status === "failed") {
- done(() => reject(new Error(status.error_message || "预览渲染失败")))
- } else {
- setTimeout(poll, 2000)
- }
- } catch (e) {
- done(() => reject(e))
- }
- }
- poll()
- })
- message.success("预览视频就绪,重新生成封面...")
- // 重试封面生成
- const retryResp = await generateCover(selectedTemplate, {
- asset_ids: assetIds,
- cover_type: "ai_frame",
- ...(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,
- },
- }
- : {}),
- })
- const retryUrl = retryResp.cover?.image_url || ""
- if (retryUrl) {
- onCoverSettingsChange({
- ...coverSettings,
- thumbnail_url: retryUrl,
- ai_suggested_time: retryResp.cover?.frame_time ?? null,
- })
- message.success("封面生成成功")
- } else {
- message.warning("封面生成未返回图片,请重试")
- }
- } catch (retryErr) {
- console.error("[Step6] 自动创建预览后重试失败:", retryErr)
- message.error("预览视频创建失败,请稍后重试")
- }
- } else if (anyErr?.__msgShown) {
+ if (anyErr?.__msgShown) {
// 拦截器已处理,不再重复弹出
} else {
let errorMsg = "封面生成失败"
@@ -310,31 +147,21 @@ export function useStep6Cover({
console.error("[Step6] 后端返回:", e.response.data)
} else if (e.request) {
errorMsg = "服务器无响应,请检查网络连接"
- console.error("[Step6] 请求无响应:", e.request)
} else if (e.message) {
errorMsg = e.message
}
message.error(errorMsg)
}
} finally {
- clearTimeout(timeoutId)
setGenerating(false)
}
}, [
selectedTemplate,
- assetIds,
+ finalVideo,
coverSettings,
onCoverSettingsChange,
generating,
- duration,
titleSettings,
- onPreviewTaskCreated,
- onSourceEditPlanIdExtracted,
- voiceMode,
- selectedVoice,
- selectedClonedVoice,
- bgm,
- bgmConfig,
])
// ── 模板操作方法 ──
@@ -391,13 +218,11 @@ export function useStep6Cover({
const selectedTemplateName =
coverTemplates.find((t) => t.id === selectedTemplateId)?.name || "默认"
- const totalDuration = duration || 30
-
return {
coverSettings,
generating,
generateAutoCover,
- totalDuration,
+ finalVideo,
showCoverSettings,
setShowCoverSettings,
showCoverEditor,
diff --git a/apps/web/src/pages/generate/hooks/useStepNavigation.ts b/apps/web/src/pages/generate/hooks/useStepNavigation.ts
index 02c46a14b..adc757144 100644
--- a/apps/web/src/pages/generate/hooks/useStepNavigation.ts
+++ b/apps/web/src/pages/generate/hooks/useStepNavigation.ts
@@ -1,9 +1,6 @@
/**
* GeneratePage 步骤导航
- * 管理步骤切换与各步骤的前置校验
- * 步骤顺序(6步):模板(1) → 素材(2) → 配音(3) → 标题(4) → 封面(5) → 确认(6)
- *
- * 原 Step4(标题) + Step5(预览) 已合并为 Step4
+ * 步骤顺序(6步):模板(1) → 素材(2) → 配音(3) → 标题(4) → 确认生成(5) → 封面(6)
*/
import { message } from "antd"
import type { TitleSettings } from "../types"
@@ -18,6 +15,8 @@ export interface UseStepNavigationOptions {
titleSettings: TitleSettings
/** 预览是否已就绪(素材已加载,可播放) */
previewReady: boolean
+ /** 是否已完成视频生成(步骤5确认生成后才能进入封面) */
+ generated: boolean
}
export interface UseStepNavigationReturn {
@@ -35,6 +34,7 @@ export const useStepNavigation = (options: UseStepNavigationOptions): UseStepNav
smartSelectedIds,
titleSettings,
previewReady,
+ generated,
} = options
const goNext = () => {
@@ -50,7 +50,7 @@ export const useStepNavigation = (options: UseStepNavigationOptions): UseStepNav
message.warning("请先进行智能匹配并选择素材")
return
}
- // Step4(合并后的标题+预览):标题必填 + 预览必须已生成
+ // Step4(标题+预览):标题必填 + 预览必须已加载
if (currentStep === 4) {
if (!titleSettings.title.trim()) {
message.warning("请选择或输入标题")
@@ -61,6 +61,11 @@ export const useStepNavigation = (options: UseStepNavigationOptions): UseStepNav
return
}
}
+ // Step5(确认生成):必须已完成生成才能进入封面
+ if (currentStep === 5 && !generated) {
+ message.warning("请先生成视频")
+ return
+ }
if (currentStep < 6) {
setCurrentStep((s) => s + 1)
}