diff --git a/apps/web/src/pages/generate/GeneratePage.tsx b/apps/web/src/pages/generate/GeneratePage.tsx index 2c822aa6d..fa48251da 100644 --- a/apps/web/src/pages/generate/GeneratePage.tsx +++ b/apps/web/src/pages/generate/GeneratePage.tsx @@ -9,7 +9,7 @@ * - 标题样式通过 CSS 层实时叠加,所见即所得 * - 最终成片仍走后端 FFmpeg 渲染(Step7 确认生成) */ -import React, { useMemo } from "react" +import React, { useMemo, useEffect } from "react" import { Modal, message } from "antd" import { useNavigate } from "react-router-dom" import type { VoiceClone } from "@/api/voice-clone" @@ -77,6 +77,8 @@ const GeneratePage: React.FC = () => { setPreviewVideo, previewModalOpen, setPreviewModalOpen, + previewTaskId, + setPreviewTaskId, } = formState /* ── 标题样式回调(Step5 样式面板 + 右侧预览 CSS 层共用) ── */ @@ -99,6 +101,14 @@ const GeneratePage: React.FC = () => { () => (materialMode === "auto" ? smartSelectedIds : selectedMaterials), [materialMode, smartSelectedIds, selectedMaterials], ) + // 素材、模板或生成参数变化时,清除旧的 previewTaskId,防止 Step7 复用过期预览产物 + // confirmGeneration 无法覆盖 duration/ratio/title_config,所以这些参数变化后必须重新创建预览 + const titleKey = JSON.stringify(titleSettings) + useEffect(() => { + setPreviewTaskId(null) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [previewAssetIds, selectedTemplate, duration, videoRatio, titleKey, bgm, setPreviewTaskId]) + const previewAssetsEnabled = currentStep >= 4 && previewAssetIds.length > 0 const { assets: previewAssets, @@ -173,6 +183,7 @@ const GeneratePage: React.FC = () => { bgm, generateCount, sourceEditPlanId, + previewTaskId, }) /* ================================================================ @@ -215,6 +226,7 @@ const GeneratePage: React.FC = () => { onApplyPreset={styleUpdaters.applyPreset} activePreset={styleUpdaters.activePreset} titlePresets={styleUpdaters.titlePresets} + onPreviewTaskCreated={setPreviewTaskId} coverSettings={coverSettings} onCoverSettingsChange={setCoverSettings} duration={duration} diff --git a/apps/web/src/pages/generate/components/GenerateStepContent.tsx b/apps/web/src/pages/generate/components/GenerateStepContent.tsx index 7f7cf93f8..6c7ad9eaa 100644 --- a/apps/web/src/pages/generate/components/GenerateStepContent.tsx +++ b/apps/web/src/pages/generate/components/GenerateStepContent.tsx @@ -76,6 +76,8 @@ export interface GenerateStepContentProps { onDismissError: () => void /* 其他 */ presetVoices: PresetVoiceItem[] + /** 预览任务创建回调——传递给 Step6CoverSettings */ + onPreviewTaskCreated?: (taskId: string) => void } export const GenerateStepContent: React.FC = (props) => { @@ -121,6 +123,7 @@ export const GenerateStepContent: React.FC = (props) = onRetry, onDismissError, presetVoices, + onPreviewTaskCreated, } = props /* 当前模板的 segments,传给 Step2 构建 clips */ @@ -190,6 +193,7 @@ export const GenerateStepContent: React.FC = (props) = assetIds={materialMode === "auto" ? smartSelectedIds : selectedMaterials} selectedTemplate={selectedTemplate} titleSettings={titleSettings} + onPreviewTaskCreated={onPreviewTaskCreated} /> ) case 7: diff --git a/apps/web/src/pages/generate/components/Step6CoverSettings.tsx b/apps/web/src/pages/generate/components/Step6CoverSettings.tsx index 8d50ded18..fc576e303 100755 --- a/apps/web/src/pages/generate/components/Step6CoverSettings.tsx +++ b/apps/web/src/pages/generate/components/Step6CoverSettings.tsx @@ -16,6 +16,8 @@ interface Step6CoverSettingsProps { selectedTemplate?: string /** Step4 标题设置,用于预览视频烧录标题 & 封面叠加标题 */ titleSettings?: import("../types").TitleSettings + /** 预览任务创建回调——将 task_id 暴露给父组件供 confirmGeneration 复用 */ + onPreviewTaskCreated?: (taskId: string) => void } const Step6CoverSettings: React.FC = (props) => { @@ -43,6 +45,7 @@ const Step6CoverSettings: React.FC = (props) => { assetIds: props.assetIds, selectedTemplate: props.selectedTemplate, titleSettings: props.titleSettings, + onPreviewTaskCreated: props.onPreviewTaskCreated, }) const handleAutoGenerate = () => { diff --git a/apps/web/src/pages/generate/hooks/generate-video/types.ts b/apps/web/src/pages/generate/hooks/generate-video/types.ts index 1c2b6e2a5..a6352d553 100755 --- a/apps/web/src/pages/generate/hooks/generate-video/types.ts +++ b/apps/web/src/pages/generate/hooks/generate-video/types.ts @@ -21,6 +21,8 @@ export interface UseGenerateVideoProps { generateCount: number /** 当前草稿 ID(URL 参数 edit_plan_id,用于后端回写任务关联) */ sourceEditPlanId?: string | null + /** 预览任务 ID(由 useStep6Cover 创建后写入,供 confirmGeneration 复用预览产物) */ + previewTaskId?: string | null } /** 生成阶段 */ diff --git a/apps/web/src/pages/generate/hooks/useGenerateFormState/index.ts b/apps/web/src/pages/generate/hooks/useGenerateFormState/index.ts index d1cffd1ff..7170cdf75 100755 --- a/apps/web/src/pages/generate/hooks/useGenerateFormState/index.ts +++ b/apps/web/src/pages/generate/hooks/useGenerateFormState/index.ts @@ -95,6 +95,10 @@ export interface GenerateFormState { setPreviewVideo: (v: GeneratedVideo | null) => void previewModalOpen: boolean setPreviewModalOpen: (open: boolean) => void + + /** 预览任务 ID(由 useStep6Cover 创建后写入,供 useGenerateVideo 复用) */ + previewTaskId: string | null + setPreviewTaskId: (id: string | null) => void } export const useGenerateFormState = (): GenerateFormState => { @@ -160,6 +164,9 @@ export const useGenerateFormState = (): GenerateFormState => { const [previewVideo, setPreviewVideo] = useState(null) const [previewModalOpen, setPreviewModalOpen] = useState(false) + /* ── 预览任务 ID(useStep6Cover 创建预览时写入,useGenerateVideo 复用) ── */ + const [previewTaskId, setPreviewTaskId] = useState(null) + /* ── 从 URL / 编辑计划加载配置 ── */ usePlanConfigLoader({ editPlanId, @@ -208,5 +215,7 @@ export const useGenerateFormState = (): GenerateFormState => { setPreviewVideo, previewModalOpen, setPreviewModalOpen, + previewTaskId, + setPreviewTaskId, } } diff --git a/apps/web/src/pages/generate/hooks/useGenerateVideo.ts b/apps/web/src/pages/generate/hooks/useGenerateVideo.ts index 58d1c3b8e..e8f1d14dc 100755 --- a/apps/web/src/pages/generate/hooks/useGenerateVideo.ts +++ b/apps/web/src/pages/generate/hooks/useGenerateVideo.ts @@ -6,6 +6,7 @@ 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" @@ -92,35 +93,67 @@ export function useGenerateVideo(props: UseGenerateVideoProps) { // 封面 URL:优先 AI 生成缩略图,兜底用户上传 const coverUrl = props.coverSettings?.thumbnail_url || props.coverSettings?.upload_url || "" - // 直接创建正式生成任务 - 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, - ...(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, - }, - } - : {}), - }) + // ── 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 路径 + } + } + + // 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, + ...(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 + } - // 从创建响应直接拿 task_id,改用新接口轮询 - const taskId = taskResp.items?.[0]?.id if (!taskId) { throw new Error("创建任务成功但未返回任务 ID,请稍后在任务列表查看") } diff --git a/apps/web/src/pages/generate/hooks/useStep6Cover.ts b/apps/web/src/pages/generate/hooks/useStep6Cover.ts index f86141680..e863559d6 100644 --- a/apps/web/src/pages/generate/hooks/useStep6Cover.ts +++ b/apps/web/src/pages/generate/hooks/useStep6Cover.ts @@ -2,7 +2,7 @@ * Step 6 封面设置 Hook * 封装封面设置的交互逻辑,对接后端封面模板 CRUD API */ -import { useCallback, useEffect, useState } from "react" +import { useCallback, useEffect, useRef, useState } from "react" import { message } from "antd" import type { CoverConfig, CoverTemplate } from "../types/cover" import { generateCover } from "@/api/generation" @@ -26,6 +26,8 @@ interface UseStep6CoverProps { selectedTemplate?: string /** Step4 标题设置,用于预览视频烧录标题 & 封面叠加标题 */ titleSettings?: TitleSettings + /** 预览任务创建回调——将 task_id 暴露给父组件供 confirmGeneration 复用 */ + onPreviewTaskCreated?: (taskId: string) => void } export function useStep6Cover({ @@ -35,8 +37,11 @@ export function useStep6Cover({ assetIds = [], selectedTemplate = "", titleSettings, + onPreviewTaskCreated, }: UseStep6CoverProps) { const [generating, setGenerating] = useState(false) + // 防竞态:记录当前预览生成的参数指纹,任务完成时校验一致性 + const previewParamsRef = useRef("") // ── 封面设置弹窗状态 ── const [showCoverSettings, setShowCoverSettings] = useState(false) @@ -151,6 +156,8 @@ export function useStep6Cover({ console.log("[Step6] 检测到预览缺失,尝试自动创建预览渲染任务...") message.info("正在准备预览视频,请稍候...") try { + // 记录当前参数指纹,用于任务完成时校验一致性(防竞态) + previewParamsRef.current = JSON.stringify({ selectedTemplate, assetIds, titleSettings }) const previewResp = await createPreview({ template_id: selectedTemplate, asset_ids: assetIds, @@ -170,6 +177,11 @@ export function useStep6Cover({ } : {}), }) + // 将预览任务 ID 暴露给父组件,供 Step7 确认生成时复用(confirmGeneration) + const currentFingerprint = JSON.stringify({ selectedTemplate, assetIds, titleSettings }) + if (previewResp.task_id && previewParamsRef.current === currentFingerprint) { + onPreviewTaskCreated?.(previewResp.task_id) + } // 轮询等待预览渲染完成:递归 setTimeout 避免请求重叠 + 120s 超时兜底 await new Promise((resolve, reject) => { let finished = false @@ -282,6 +294,7 @@ export function useStep6Cover({ generating, duration, titleSettings, + onPreviewTaskCreated, ]) // ── 模板操作方法 ──