diff --git a/apps/web/e2e/core-generation.spec.ts b/apps/web/e2e/core-generation.spec.ts index 0637debc4..c282269f3 100755 --- a/apps/web/e2e/core-generation.spec.ts +++ b/apps/web/e2e/core-generation.spec.ts @@ -185,6 +185,13 @@ test.describe("Core generation flow", () => { await expect(page.locator(".xx-choice-item.selected")).toBeVisible() await page.getByRole("button", { name: "下一步" }).click() + // Step1 下一步弹出数量选择弹窗(Issue #1677 固定6步:模板→素材→配音→标题→确认生成→封面) + // 单视频流程:默认 1 个,点击「生成 1 个视频」进入步骤2 + await expect(page.getByRole("heading", { name: "要生成几个视频?" })).toBeVisible({ + timeout: 10_000, + }) + await page.getByRole("button", { name: "生成 1 个视频" }).click() + // Step 2: select material (card grid UI) await expect(page.getByRole("heading", { name: /选择素材/ })).toBeVisible() const librarySelect = page.locator("select").first() @@ -255,9 +262,18 @@ test.describe("Core generation flow", () => { expect(genData.items.length).toBeGreaterThan(0) expect(genData.items[0].id).toBeTruthy() - // 单视频(N=1):点击「确认生成视频」后直接跳 Step 5 封面(与旧流程一致) + // 单视频(N=1):点击「确认生成视频」后跳 Step 5「确认生成」,展示实时渲染进度 + await expect(page.getByRole("heading", { name: "🎬 确认生成" })).toBeVisible({ + timeout: 30_000, + }) + + // 等待渲染完成:进度卡变为「视频生成完成」(最长等待 3 分钟) + await expect(page.getByText("视频生成完成")).toBeVisible({ timeout: 180_000 }) + + // 全部完成后「下一步:选择封面」解锁,点击进入 Step 6 + await page.getByRole("button", { name: /下一步:选择封面/ }).click() await expect(page.getByRole("heading", { name: /选择封面/ })).toBeVisible({ - timeout: 180_000, + timeout: 30_000, }) } else { console.log(`[E2E] Generate API returned ${genResp.status()}, wizard flow test still passes`) diff --git a/apps/web/src/pages/generate/GeneratePage.tsx b/apps/web/src/pages/generate/GeneratePage.tsx index 57fad236c..ef48442d7 100644 --- a/apps/web/src/pages/generate/GeneratePage.tsx +++ b/apps/web/src/pages/generate/GeneratePage.tsx @@ -1,11 +1,12 @@ /** - * 智能剪辑页面(Issue #1677 多视频批量生成) - * 5 步向导:选择模板(弹数量) → 素材 → 配音 → 标题(预览+确认生成) → 封面 + * 智能剪辑页面(Issue #1677 多视频批量生成,修正版) + * 固定 6 步向导:模板(弹数量) → 素材 → 配音 → 标题 → 确认生成 → 封面,单视频与批量完全一致 * * 架构: - * - N=1:前端 Canvas 实时预览(FrontendPreviewPlayer),零回归 - * - N>1:服务器批量预览(POST /generation/preview?preview_count=N), - * N 个变体分别轮询,网格展示、独立可播放、CSS 标题浮层实时叠加、勾选批量生成 + * - 预览全部为纯前端 Canvas 实时播放(FrontendPreviewPlayer),不调任何后端渲染接口: + * N=1 单播放器;N>1 CanvasPreviewGrid(variantSeed 让素材排布/起始点不同,画面有差异) + * - 步骤5确认生成:正式生成接口(count + titles[]/voice_library_ids[]/cover_urls[]), + * 批量时逐任务独立进度/失败重试(BatchGenerationGrid) */ import React, { useMemo, useState, useEffect, useRef, useCallback } from "react" import { message } from "antd" @@ -16,7 +17,7 @@ import { useCloneProgress } from "@/hooks/useCloneProgress" import CloneModal from "@/components/voice/CloneModal" import GenerateHeader from "./components/GenerateHeader" import FrontendPreviewPlayer from "./components/FrontendPreviewPlayer" -import ServerPreviewGrid from "./components/ServerPreviewGrid" +import CanvasPreviewGrid from "./components/CanvasPreviewGrid" import PreviewCountModal from "./components/PreviewCountModal" import GenerateStepsBar from "./components/GenerateStepsBar" import GenerateStepContent from "./components/GenerateStepContent" @@ -24,12 +25,11 @@ import GenerateStepActions from "./components/GenerateStepActions" import { useGenerateFormState } from "./hooks/useGenerateFormState" import { useStepNavigation } from "./hooks/useStepNavigation" import { useGenerateVideo } from "./hooks/useGenerateVideo" -import { useBatchPreview } from "./hooks/useBatchPreview" + import { usePreviewAssets } from "./hooks/usePreviewAssets" import { useTitleStyleUpdaters } from "./hooks/useStep4Title/useTitleStyleUpdaters" import { getAssetsByKind } from "@/api/assets" import { previewTts } from "@/api/tts" -import { calculateResolution } from "./utils/calculateResolution" import "./generate.css" const GeneratePage: React.FC = () => { @@ -140,8 +140,10 @@ const GeneratePage: React.FC = () => { return } + // 批量模式下 TTS 文案跟随变体0标题;单视频跟随主标题 + const ttsTitle = isBatch ? previewTitles[0] || "" : titleSettings.title const voiceId = selectedClonedVoice || selectedVoice - if (!voiceId || !titleSettings.title) { + if (!voiceId || !ttsTitle) { setPreviewVoiceAudioUrl(null) return } @@ -151,7 +153,7 @@ const GeneratePage: React.FC = () => { ttsAbortRef.current = controller let cancelled = false - previewTts({ text: titleSettings.title, voice_id: voiceId }) + previewTts({ text: ttsTitle, voice_id: voiceId }) .then((res) => { if (!cancelled && res.audio_url) { setPreviewVoiceAudioUrl(res.audio_url) @@ -168,7 +170,14 @@ const GeneratePage: React.FC = () => { cancelled = true controller.abort() } - }, [selectedVoice, selectedClonedVoice, titleSettings.title, voiceMaterials]) + }, [ + selectedVoice, + selectedClonedVoice, + titleSettings.title, + previewTitles, + isBatch, + voiceMaterials, + ]) /* ── 克隆声音 ── */ const { addClone } = useCloneProgress() @@ -207,76 +216,8 @@ const GeneratePage: React.FC = () => { previewAssetsEnabled, ) - /* ── 预览就绪 ── */ - const singlePreviewReady = useMemo( - () => previewAssetsReady && !!currentTemplate, - [previewAssetsReady, currentTemplate], - ) - - /* ── 批量服务器预览(N>1) ── */ - const buildPreviewRequest = useCallback(() => { - const { width, height } = calculateResolution(videoRatio || "9:16") - const voiceLibraryId = - voiceMode === "clone" ? selectedClonedVoice || selectedVoice || "" : selectedVoice || "" - return { - template_id: selectedTemplate, - asset_ids: previewAssetIds, - output_width: width, - output_height: height, - video_ratio: videoRatio, - voice_library_id: voiceLibraryId, - ...(voiceModePerVideo && voiceLibraryIds.some(Boolean) - ? { voice_library_ids: voiceLibraryIds.map((id) => id || voiceLibraryId) } - : {}), - preview_count: previewCount, - // 批量预览不传 titles/title_config:标题文字与样式由前端 CSS 浮层实时叠加 - // (用户改标题/样式即时可见,无需重渲染);正式生成时才把标题烧录进成片 - duration: duration || undefined, - bgm_config: { - enabled: bgm !== false, - ...(bgmConfig?.music_id ? { preset_id: bgmConfig.music_id } : {}), - }, - ...(storedSourceEditPlanId || sourceEditPlanId - ? { source_edit_plan_id: storedSourceEditPlanId || sourceEditPlanId || undefined } - : {}), - } - }, [ - videoRatio, - voiceMode, - selectedClonedVoice, - selectedVoice, - selectedTemplate, - previewAssetIds, - voiceModePerVideo, - voiceLibraryIds, - previewCount, - duration, - bgm, - bgmConfig, - storedSourceEditPlanId, - sourceEditPlanId, - ]) - - const { - variants, - status: batchPreviewStatus, - progress: batchPreviewProgress, - failedCount: batchFailedCount, - trigger: retryBatchPreview, - } = useBatchPreview({ - enabled: isBatch && currentStep >= 4 && previewAssetIds.length > 0 && !!selectedTemplate, - buildRequest: buildPreviewRequest, - onPreviewTasksCreated: (_taskIds, planId) => { - if (planId) setStoredSourceEditPlanId(planId) - }, - }) - - /** 批量预览就绪:全部变体渲染完成 */ - const batchPreviewReady = - isBatch && variants.length > 0 && variants.every((v) => v.status === "ready") - - /** 步骤4整体预览就绪状态 */ - const previewReady = isBatch ? batchPreviewReady : singlePreviewReady + /* ── 预览就绪:纯前端 Canvas 预览,素材详情加载完即可秒开(单视频/批量一致) ── */ + const previewReady = previewAssetsReady && !!currentTemplate /* ── 勾选变体 ── */ const toggleVariantSelect = useCallback( @@ -296,8 +237,10 @@ const GeneratePage: React.FC = () => { generated, generateError, generatedVideos, + batchTasks, generate: handleGenerate, retry: handleRetryGenerate, + retryBatchTask: handleRetryBatchTask, dismissError: handleDismissError, download: handleDownload, share: handleShare, @@ -365,31 +308,39 @@ const GeneratePage: React.FC = () => { ], ) - /* ── 步骤4「确认生成视频」 ── */ + /* ── 步骤4「确认生成视频」:校验通过 → 创建正式生成任务 → 跳步骤5看实时进展 ── */ const handleConfirmGenerate = useCallback(async () => { - // 标题校验 - if (previewTitles.some((t) => !t?.trim())) { - message.warning("请为每个视频输入标题") - return - } - if (isBatch && selectedVariantIds.length === 0) { - message.warning("请至少勾选一个视频") + // 标题校验:批量只校验已勾选的变体;单视频校验主标题 + if (isBatch) { + if (selectedVariantIds.length === 0) { + message.warning("请至少勾选一个视频") + return + } + const missing = selectedVariantIds.some((i) => !previewTitles[i]?.trim()) + if (missing) { + message.warning("请为每个勾选的视频输入标题") + return + } + } else if (!titleSettings.aiAutoSelect && !titleSettings.title?.trim()) { + message.warning("请先选择或输入标题") return } if (!previewReady) { - message.warning("预览视频正在加载,请稍候") + message.warning("预览素材正在加载,请稍候") return } const ok = await handleGenerate() - if (ok && !isBatch) { + if (ok) { + // 单视频与批量一致:任务创建成功后进入步骤5「确认生成」看实时渲染进展 setCurrentStep(5) } - // 批量模式停留在步骤4,右侧网格显示生成进度,完成后点"下一步"进封面 }, [ isBatch, - selectedVariantIds.length, - previewReady, + selectedVariantIds, previewTitles, + titleSettings.aiAutoSelect, + titleSettings.title, + previewReady, handleGenerate, setCurrentStep, ]) @@ -403,25 +354,20 @@ const GeneratePage: React.FC = () => { selectedMaterials, smartSelectedIds, titleSettings, - previewReady, generated, - previewTitles, - selectedCount: isBatch ? selectedVariantIds.length : 1, onOpenCountModal: () => setCountModalOpen(true), }) /* ── 最终成片(单视频右侧播放) ── */ const finalVideo = generatedVideos[0] - /** 批量生成进度文案 */ - const batchGeneratingText = useMemo(() => { - if (batchPreviewStatus === "loading") - return `AI 正在渲染 ${previewCount} 个预览视频… ${batchPreviewProgress}%` - if (batchPreviewStatus === "failed") return "预览渲染失败,请重试" - if (batchPreviewStatus === "partial_failed") - return `${batchFailedCount} 个预览失败,可重新生成或勾选成功的视频` - return "" - }, [batchPreviewStatus, batchPreviewProgress, batchFailedCount, previewCount]) + /* ── 布局 class:步骤4标题页=预览+标题侧栏;步骤5/6批量=整行宽;步骤1~3=整行宽 ── */ + const layoutClassName = useMemo(() => { + if (currentStep < 4) return "xx-generate-layout full-width" + if (currentStep === 4) return "xx-generate-layout step4-layout" + // 步骤5/6:批量网格需要整行宽度;单视频保持 表单+右侧成片 两栏 + return isBatch ? "xx-generate-layout full-width" : "xx-generate-layout" + }, [currentStep, isBatch]) /* ================================================================ 渲染 @@ -433,16 +379,12 @@ const GeneratePage: React.FC = () => { -
- {/* ════ 步骤4:左侧预览大区域 ════ */} +
+ {/* ════ 步骤4:左侧预览大区域(纯前端 Canvas 实时预览) ════ */} {currentStep === 4 && !!currentTemplate && (
{!isBatch ? ( - /* 单视频:前端 Canvas 实时预览(与旧版一致) */ + /* 单视频:前端 Canvas 实时预览(与旧版一致,零回归) */ { onTitlePositionChange={styleUpdaters.updateTitlePosition} /> ) : ( - /* 批量:服务器预览网格 */ + /* 批量:N 个前端 Canvas 预览网格(不调任何后端渲染接口,秒开) */

🎬 {previewCount} 个视频预览

- {batchPreviewStatus === "loading" && ( - - {batchPreviewProgress}% - - )} - {(batchPreviewStatus === "failed" || batchPreviewStatus === "partial_failed") && ( - - )} + + 实时预览,勾选要生成的视频 +
- - {batchGeneratingText && ( -
- {batchGeneratingText} -
- )} - - - - {/* 生成中进度(批量) */} - {generating && ( -
-
-
-
- ⏳ 正在渲染 {selectedVariantIds.length} 个最终视频… {Math.round(progress)} - % -
-
- 生成过程中可以切换到其他页面,完成后可在任务历史查看 -
-
-
-
-
-
-
- )} - - {generateError && !generating && ( -
-
-
生成失败
-
{generateError}
-
- -
- )} - - {generated && !generating && ( -
-
-
✅ 视频生成完成!
-
- 共生成 {generatedVideos.length} 条视频,点击「下一步」为每个视频选择封面 -
-
-
- )}
)}
)} - {/* ════ 右侧:步骤1~3 表单 / 步骤4 标题边栏 / 步骤5 封面 ════ */} + {/* ════ 右侧:步骤1~3 表单 / 步骤4 标题边栏 / 步骤5 确认生成进度 / 步骤6 封面 ════ */}
{ progress={progress} generatedVideos={generatedVideos} onRetry={handleRetryGenerate} + onRetryBatchTask={handleRetryBatchTask} onDismissError={handleDismissError} + batchTasks={batchTasks} previewCount={previewCount} previewTitles={previewTitles} onPreviewTitlesChange={setPreviewTitles} @@ -631,16 +498,16 @@ const GeneratePage: React.FC = () => { />
- {/* ════ 步骤5(封面):成片播放器(单视频) ════ */} - {currentStep === 5 && !isBatch && generated && finalVideo && ( + {/* ════ 步骤5/6(单视频):右侧成片播放器 ════ */} + {currentStep >= 5 && !isBatch && generated && finalVideo && (
+
+ ) + })} +
+
+ ) +} + +export default BatchGenerationGrid diff --git a/apps/web/src/pages/generate/components/CanvasPreviewGrid.tsx b/apps/web/src/pages/generate/components/CanvasPreviewGrid.tsx new file mode 100644 index 000000000..1d0b17674 --- /dev/null +++ b/apps/web/src/pages/generate/components/CanvasPreviewGrid.tsx @@ -0,0 +1,95 @@ +/** + * 批量前端 Canvas 实时预览网格(Issue #1677 修正方案) + * + * N 个 FrontendPreviewPlayer 网格排列: + * - 纯前端 Canvas + video 元素实时播放素材片段,不调任何后端渲染接口 + * - variantSeed 让每个变体素材排布/起始点不同,画面有可见差异 + * - 各自叠加独立标题浮层(variantTitle),标题样式全局共用 + * - 勾选框决定提交时生成哪些变体 + */ +import React from "react" +import type { AssetItem } from "@/api/assets" +import type { EditingTemplate } from "@/api/editing-planner" +import type { TitleSettings } from "../types" +import FrontendPreviewPlayer from "./FrontendPreviewPlayer" + +interface CanvasPreviewGridProps { + count: number + assets: AssetItem[] + template: EditingTemplate | null + videoRatio: string + titles: string[] + titleSettings: TitleSettings + /** 共用配音预览音频(仅第 1 个变体播放,避免多路音频重叠) */ + voiceAudioUrl?: string + /** 勾选的变体序号 */ + selectedIds: number[] + onToggleSelect: (index: number) => void + /** 生成中禁止勾选 */ + selectable?: boolean +} + +const CanvasPreviewGrid: React.FC = ({ + count, + assets, + template, + videoRatio, + titles, + titleSettings, + voiceAudioUrl, + selectedIds, + onToggleSelect, + selectable = true, +}) => { + return ( +
+ {Array.from({ length: count }, (_, i) => { + const checked = selectedIds.includes(i) + return ( +
+
+ +
+ 0} + variantSeed={i + 1} + variantTitle={titles[i] || ""} + voiceAudioUrl={i === 0 ? voiceAudioUrl : undefined} + compact + titleSettings={{ + title: titles[i] || "", + size: titleSettings.size, + font: titleSettings.font, + color: titleSettings.color, + position: titleSettings.position as "top" | "center" | "bottom" | "custom", + bold: titleSettings.bold, + italic: titleSettings.italic, + stroke: titleSettings.stroke, + shadow: titleSettings.shadow, + posX: titleSettings.posX, + posY: titleSettings.posY, + }} + /> +
+ ) + })} +
+ ) +} + +export default CanvasPreviewGrid diff --git a/apps/web/src/pages/generate/components/FrontendPreviewPlayer.tsx b/apps/web/src/pages/generate/components/FrontendPreviewPlayer.tsx index 35387d1fe..b9b721b70 100644 --- a/apps/web/src/pages/generate/components/FrontendPreviewPlayer.tsx +++ b/apps/web/src/pages/generate/components/FrontendPreviewPlayer.tsx @@ -41,6 +41,16 @@ interface FrontendPreviewPlayerProps { posY?: number | null } onTitlePositionChange?: (posX: number, posY: number) => void + /** + * 变体种子(批量生成 #1677):同一批素材在不同变体中采用不同的素材顺序与 + * 片段起始点,让 N 个 Canvas 预览画面有差异(纯前端随机剪辑模拟,不调后端)。 + * 0 / 不传 = 单视频,排布与旧版完全一致(零回归)。 + */ + variantSeed?: number + /** 变体标题文字(批量时每个预览独立标题,叠加在画面上);不传用 titleSettings.title */ + variantTitle?: string + /** 紧凑模式(批量网格中使用,缩小内边距/标题尺寸) */ + compact?: boolean } function formatTime(seconds: number): string { @@ -52,10 +62,23 @@ function formatTime(seconds: number): string { /** * 将素材映射为播放片段(复用原逻辑) */ +/** 简单可复现随机数(mulberry32),同一种子产出稳定排布,避免每次渲染抖动 */ +function seededRandom(seed: number): () => number { + let a = seed >>> 0 + return () => { + a |= 0 + a = (a + 0x6d2b79f5) | 0 + let t = Math.imul(a ^ (a >>> 15), 1 | a) + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t + return ((t ^ (t >>> 14)) >>> 0) / 4294967296 + } +} + function buildPlaybackSegments( assets: AssetItem[], template: EditingTemplate | null, serverClips?: EditPlanClip[], + variantSeed = 0, ): PlaybackSegment[] { if (!assets.length) return [] @@ -79,18 +102,40 @@ function buildPlaybackSegments( } } - // Fallback: 本地构建片段(与旧行为一致) + // Fallback: 本地构建片段 + // variantSeed=0(单视频):与旧行为完全一致(素材原序、起始点 0),零回归 + // variantSeed>0(批量变体):素材顺序按种子轮换 + 片段起始点在素材内偏移, + // 模拟后端"AI 随机剪辑出不同版本",让 N 个预览画面有可见差异 const templateSegments = template?.segments || [] const segments: PlaybackSegment[] = [] + const orderedAssets = variantSeed > 0 ? [...assets] : assets + if (variantSeed > 0 && orderedAssets.length > 1) { + const rand = seededRandom(variantSeed * 7919 + 13) + // 素材轮换:把数组旋转 (seed % n) 位,再对后半段做一次稳定交换 + const n = orderedAssets.length + const rotate = variantSeed % n + orderedAssets.push(...orderedAssets.splice(0, rotate)) + const swapA = Math.floor(rand() * n) + const swapB = Math.floor(rand() * n) + if (swapA !== swapB) { + ;[orderedAssets[swapA], orderedAssets[swapB]] = [orderedAssets[swapB], orderedAssets[swapA]] + } + } - assets.forEach((asset, i) => { + orderedAssets.forEach((asset, i) => { const assetDuration = asset.duration || asset.metadata?.duration || 30 const tplSeg = templateSegments[i] || templateSegments[templateSegments.length - 1] const segDuration = tplSeg ? Math.min(tplSeg.duration_max, Math.max(tplSeg.duration_min, assetDuration)) : Math.min(assetDuration, 10) - const startTime = 0 + let startTime = 0 + if (variantSeed > 0 && assetDuration - segDuration > 1) { + const rand = seededRandom(variantSeed * 104729 + i * 31 + 7) + // 起始点在素材可用区间内随机偏移(至少留 0.5s 余量) + const maxStart = Math.max(0, assetDuration - segDuration - 0.5) + startTime = Math.round(rand() * maxStart * 10) / 10 + } const endTime = Math.min(startTime + segDuration, assetDuration) const videoUrl = asset.file_url || asset.storage_key @@ -109,11 +154,16 @@ const FrontendPreviewPlayer: React.FC = ({ voiceAudioUrl, titleSettings, onTitlePositionChange, + variantSeed = 0, + variantTitle, + compact = false, }) => { const segments = useMemo( - () => buildPlaybackSegments(assets, template, serverClips), - [assets, template, serverClips], + () => buildPlaybackSegments(assets, template, serverClips, variantSeed), + [assets, template, serverClips, variantSeed], ) + // 批量变体:标题文字取 variantTitle,样式仍由全局 titleSettings 控制 + const effectiveTitle = variantTitle ?? titleSettings?.title // ── ASS 坐标系参数(与后端 ass_subtitle_builder.py 一致) ── const TITLE_MARGIN_TOP = 120 @@ -234,7 +284,7 @@ const FrontendPreviewPlayer: React.FC = ({ // ── Canvas 播放器(WebCodecs 路径) ── const canvasTitle = titleSettings ? { - text: titleSettings.title || "标题预览", + text: effectiveTitle || "标题预览", fontSize: titleSettings.size, fontFamily: titleSettings.font || "思源黑体", color: titleSettings.color || "#ffffff", @@ -520,13 +570,15 @@ const FrontendPreviewPlayer: React.FC = ({ style={{ position: "relative", width: "100%", - maxWidth: 280, + maxWidth: compact ? "100%" : 280, + margin: compact ? 0 : "0 auto", aspectRatio: "9 / 16", background: "#0a0a0a", - borderRadius: 24, + borderRadius: compact ? 10 : 24, overflow: "hidden", - boxShadow: - "0 4px 6px -1px rgba(0,0,0,0.3), 0 20px 50px -12px rgba(0,0,0,0.5), inset 0 0 0 1px rgba(255,255,255,0.06)", + boxShadow: compact + ? "inset 0 0 0 1px rgba(255,255,255,0.06)" + : "0 4px 6px -1px rgba(0,0,0,0.3), 0 20px 50px -12px rgba(0,0,0,0.5), inset 0 0 0 1px rgba(255,255,255,0.06)", }} > {/* ── Canvas 渲染层(WebCodecs 路径) ── */} @@ -610,8 +662,8 @@ const FrontendPreviewPlayer: React.FC = ({ ? { top: "50%", transform: "translate(-50%, -50%)" } : { bottom: `${titleBottomPct}%` }), }), - pointerEvents: "auto", - cursor: onTitlePositionChange ? "grab" : "default", + pointerEvents: onTitlePositionChange && variantSeed === 0 ? "auto" : "none", + cursor: onTitlePositionChange && variantSeed === 0 ? "grab" : "default", touchAction: "none", userSelect: "none", WebkitUserSelect: "none", @@ -641,7 +693,7 @@ const FrontendPreviewPlayer: React.FC = ({ : undefined, }} > - {titleSettings.title.split(/[//]/).map((part, i) => ( + {(effectiveTitle || "").split(/[//]/).map((part, i) => ( {i > 0 &&
} {part} diff --git a/apps/web/src/pages/generate/components/GenerateStepActions.tsx b/apps/web/src/pages/generate/components/GenerateStepActions.tsx index 0f816e23d..c25e75abe 100644 --- a/apps/web/src/pages/generate/components/GenerateStepActions.tsx +++ b/apps/web/src/pages/generate/components/GenerateStepActions.tsx @@ -1,10 +1,10 @@ /** - * GeneratePage 步骤底部操作按钮(Issue #1677 改造后 5 步) + * GeneratePage 步骤底部操作按钮(Issue #1677 修正:固定 6 步) * * 步骤 1~3:上一步 / 下一步 - * 步骤 4(标题+预览+确认生成):确认生成按钮在右侧边栏底部(含勾选数量), - * 渲染中显示进度;生成完成后显示"下一步 → 选择封面" - * 步骤 5(选择封面):仅上一步 + * 步骤 4(选择标题):「✨ 确认生成视频 / 确认生成 N 个视频」→ 创建正式生成任务,成功后跳步骤5 + * 步骤 5(确认生成):渲染进度页,全部完成后「下一步:选择封面」;仅上一步 + * 步骤 6(选择封面):仅上一步 */ import React from "react" @@ -12,7 +12,7 @@ export interface GenerateStepActionsProps { currentStep: number onPrev: () => void onNext: () => void - /** 步骤4:确认生成视频(校验 + 创建渲染任务 + 成功后进入步骤5) */ + /** 步骤4:确认生成视频(校验 + 创建渲染任务) */ onConfirmGenerate: () => void | Promise generating: boolean generated: boolean @@ -41,26 +41,19 @@ const GenerateStepActions: React.FC = ({ ) } - /* 步骤 4:标题+预览+确认生成 */ + /* 步骤 4:选择标题 — 确认生成 */ if (currentStep === 4) { if (generating) { return ( ) } if (generateError) { return ( - ) - } - if (generated) { - return ( - ) } @@ -71,7 +64,23 @@ const GenerateStepActions: React.FC = ({ ) } - /* 步骤 5(封面,最后一步):无主按钮 */ + /* 步骤 5:确认生成进度页 — 全部完成后下一步进封面 */ + if (currentStep === 5) { + if (generated) { + return ( + + ) + } + return ( + + ) + } + + /* 步骤 6(封面,最后一步):无主按钮 */ return null } diff --git a/apps/web/src/pages/generate/components/GenerateStepContent.tsx b/apps/web/src/pages/generate/components/GenerateStepContent.tsx index 47a1a7ba8..639c99aa5 100644 --- a/apps/web/src/pages/generate/components/GenerateStepContent.tsx +++ b/apps/web/src/pages/generate/components/GenerateStepContent.tsx @@ -1,6 +1,7 @@ /** * GeneratePage 步骤内容渲染 - * 步骤顺序(5步,Issue #1677):模板(1) → 素材(2) → 配音(3) → 标题+预览+确认生成(4) → 封面(5) + * 步骤顺序(6步,Issue #1677 修正):模板(1) → 素材(2) → 配音(3) → 标题(4) → 确认生成(5) → 封面(6) + * 步骤4预览(Canvas 网格)与步骤5进度(批量渲染网格)由 GeneratePage 直接渲染在左侧大区域。 */ import React from "react" import type { EditingTemplate } from "@/api/editing-planner" @@ -12,6 +13,8 @@ import Step2MaterialSelect from "../components/Step2MaterialSelect" import Step3VoiceWithMode from "./Step3VoiceWithMode" import Step4TitleSettings from "../components/Step4TitleSettings" import Step6CoverSettings from "../components/Step6CoverSettings" +import BatchGenerationGrid from "./BatchGenerationGrid" +import type { BatchTaskState } from "../hooks/generate-video/useGenerationPolling" import type { GeneratedVideo } from "@/api/template-editor" export interface GenerateStepContentProps { @@ -54,7 +57,10 @@ export interface GenerateStepContentProps { progress: number generatedVideos: GeneratedVideo[] onRetry: () => void + onRetryBatchTask: (taskId: string) => void onDismissError: () => void + /** 批量:每个正式生成任务的独立状态(步骤5进度网格) */ + batchTasks: BatchTaskState[] /** BGM 开关 */ bgm: boolean /** BGM 配置(来自模板) */ @@ -102,7 +108,14 @@ export const GenerateStepContent: React.FC = (props) = selectedVoice, onSelectedVoiceChange, onServerClipsChange, + generating, + generated, + generateError, + progress, + onRetry, generatedVideos, + batchTasks, + onRetryBatchTask, previewCount, previewTitles, onPreviewTitlesChange, @@ -176,6 +189,62 @@ export const GenerateStepContent: React.FC = (props) = /> ) case 5: + /* 确认生成页:批量=逐任务进度网格;单视频=进度状态卡(成片播放器在左侧大区域) */ + if (previewCount > 1) { + return ( + + ) + } + /* 单视频:渲染进度 / 失败重试 / 完成提示(成片播放器在右侧栏) */ + return ( +
+

🎬 确认生成

+ {generating && ( +
+
+
+
+ ⏳ 视频渲染中… {Math.round(progress)}% +
+
+ 生成过程中可以切换到其他页面,完成后可在任务历史查看 +
+
+
+
+
+
+
+ )} + {generateError && !generating && ( +
+
+
生成失败
+
{generateError}
+
+ +
+ )} + {generated && !generating && ( +
+
+
✅ 视频生成完成!
+
右侧可预览成片,点击「下一步」选择封面
+
+
+ )} +
+ ) + case 6: return ( void - /** 是否显示勾选框(确认生成前) */ - selectable?: boolean -} - -const ServerPreviewGrid: React.FC = ({ - variants, - titles, - titleStyle, - selectedIds, - onToggleSelect, - selectable = true, -}) => { - if (variants.length === 0) return null - - return ( -
- {variants.map((v) => { - const selected = selectedIds.includes(v.index) - const titleText = titles[v.index] || "" - return ( -
{ - if (selectable && v.status === "ready") onToggleSelect(v.index) - }} - role="button" - tabIndex={0} - > - {/* 勾选框 */} - {selectable && v.status === "ready" && ( -
- {selected && "✓"} -
- )} - - {/* 变体序号 */} -
视频 {v.index + 1}
- - {/* 视频区域 */} -
- {v.status === "loading" && ( -
- -
-
-
- {v.progress}% -
- )} - {v.status === "failed" && ( -
- - {v.error || "预览失败"} -
- )} - {v.status === "ready" && v.videoUrl && ( - <> -
- - {/* 底部状态 */} -
- {v.status === "ready" && selected && ( - - 已选择 - - )} - {v.status === "ready" && !selected && selectable && ( - 点击卡片取消/勾选 - )} -
-
- ) - })} -
- ) -} - -export default ServerPreviewGrid diff --git a/apps/web/src/pages/generate/components/Step4TitleSettings.tsx b/apps/web/src/pages/generate/components/Step4TitleSettings.tsx index 7c92a93ba..064bc167a 100644 --- a/apps/web/src/pages/generate/components/Step4TitleSettings.tsx +++ b/apps/web/src/pages/generate/components/Step4TitleSettings.tsx @@ -1,18 +1,22 @@ /** - * Step 4 选择标题(Issue #1677 批量生成改造) + * Step 4 选择标题(Issue #1677 批量生成) * - * 布局(由 GeneratePage 编排):左侧大区域预览,右侧边栏标题设置。 - * 本组件渲染在右侧边栏: - * - 标题文字:1 个视频 1 个输入框;N 个视频 N 个输入框各自独立 + * 布局(由 GeneratePage 编排):左侧大区域实时预览(单=大播放器,批量=Canvas 网格), + * 右侧边栏标题设置。本组件渲染在右侧边栏: + * - 单视频:AI 标题生成器 + AutoComplete 标题库(与旧版完全一致,零回归) + * - 批量:N 个独立标题输入框(AutoComplete 支持标题库选择)+ 批量 AI 生成 + * (一次生成 N 个标题,分别填入各变体,可单独换一个) * - 标题样式(字体/颜色/位置/大小/粗斜描边/预设):全局统一 */ -import React from "react" -import { AutoComplete, Input } from "antd" +import React, { useMemo, useState } from "react" +import { AutoComplete, Input, message } from "antd" +import { LoadingOutlined } from "@ant-design/icons" import type { TitleSettings } from "../types" import { POSITION_OPTIONS, FONT_OPTIONS } from "../constants" import { useStep4Title } from "../hooks/useStep4Title" import AiTitleGenerator from "./title/AiTitleGenerator" import TitleStylePanel from "./title/TitleStylePanel" +import { AI_TITLE_TEMPLATES } from "../constants" interface Step4TitleSettingsProps { titleSettings: TitleSettings @@ -38,6 +42,36 @@ interface Step4TitleSettingsProps { onPreviewTitlesChange?: (titles: string[]) => void } +/** 从本地 AI 标题模板池按主题词生成 N 个不同标题(与单视频 AI 生成同源) */ +function buildBatchAiTitles(topic: string, count: number): string[] { + const styles: Array<"catchy" | "emotional" | "informative"> = [ + "catchy", + "emotional", + "informative", + ] + const pool: string[] = [] + styles.forEach((style) => { + const templates = AI_TITLE_TEMPLATES[style] || [] + templates.forEach((tpl) => pool.push(tpl.replace(/\{topic\}/g, topic))) + }) + // 洗牌后取前 count 个;不足则轮转补齐 + const shuffled = [...pool].sort(() => Math.random() - 0.5) + const out: string[] = [] + for (let i = 0; i < count; i++) { + out.push(shuffled[i % shuffled.length] || "") + } + return out +} + +function extractTopic(text: string): string { + const keywords = text + .replace(/[,。!?、,.!?]/g, " ") + .split(/\s+/) + .filter(Boolean) + if (keywords.length === 0) return "这个话题" + return keywords.slice(0, 3).join("") +} + const Step4TitleSettings: React.FC = (props) => { const t = useStep4Title(props) const { @@ -57,8 +91,10 @@ const Step4TitleSettings: React.FC = (props) => { } = props const isBatch = previewCount > 1 + const [batchAiLoading, setBatchAiLoading] = useState(false) + const [batchAiTopic, setBatchAiTopic] = useState("") - /** 更新单个变体标题;变体0同步写回 titleSettings.title(全局样式面板/草稿保存依赖) */ + /** 更新单个变体标题;变体0同步写回 titleSettings.title(全局样式面板/草稿/TTS 链路依赖) */ const updateVariantTitle = (index: number, val: string) => { if (!previewTitles || !onPreviewTitlesChange) return const next = [...previewTitles] @@ -69,12 +105,43 @@ const Step4TitleSettings: React.FC = (props) => { } } + /** 批量 AI 生成:按主题词生成标题,分别填入 N 个变体 */ + const handleBatchAiGenerate = async (onlyEmpty = false) => { + if (!onPreviewTitlesChange || !previewTitles) return + const topic = (batchAiTopic || t.aiTitleInput || "").trim() + if (!topic) { + message.warning("请先输入主题词,例如:萌宠日常、旅行vlog") + return + } + setBatchAiLoading(true) + try { + // 与单视频一致:本地模板模拟 AI 生成(1200ms 体验延迟) + await new Promise((resolve) => setTimeout(resolve, 800)) + const picked = buildBatchAiTitles(extractTopic(topic), previewCount) + const next = [...previewTitles] + for (let i = 0; i < previewCount; i++) { + if (onlyEmpty && next[i]?.trim()) continue + if (picked[i]) next[i] = picked[i] + } + onPreviewTitlesChange(next) + if (next[0]) t.updateTitle(next[0]) + message.success(`已为 ${previewCount} 个视频生成标题,可单独修改`) + } finally { + setBatchAiLoading(false) + } + } + + const titleOptions = useMemo( + () => t.userTitles.map((ut) => ({ label: ut.content, value: ut.content })), + [t.userTitles], + ) + return (

📝 选择标题

{!isBatch ? ( - /* ── 单视频:原有 AI 标题 + 输入框(保持不变) ── */ + /* ── 单视频:原有 AI 标题 + 输入框(保持不变,零回归) ── */ <> {t.titleSettings.aiAutoSelect ? ( <> @@ -144,7 +211,7 @@ const Step4TitleSettings: React.FC = (props) => { t.updateTitle(val || "") onPreviewTitlesChange?.([val || ""]) }} - options={t.userTitles.map((ut) => ({ label: ut.content, value: ut.content }))} + options={titleOptions} filterOption={(inputValue, option) => { const title = (option?.label || option?.value || "") as string return title.toLowerCase().includes((inputValue || "").toLowerCase()) @@ -155,7 +222,7 @@ const Step4TitleSettings: React.FC = (props) => { )} ) : ( - /* ── 批量:N 个独立标题输入框(CSS 浮层实时叠加到对应预览) ── */ + /* ── 批量:AI 批量生成 + N 个独立标题输入框(AutoComplete 支持标题库) ── */
= (props) => { > 为每个视频输入独立标题,修改会实时叠加到左侧对应视频上。标题样式(字体/颜色/位置)全局统一。
+ + {/* 批量 AI 标题 */} +
+ { + setBatchAiTopic(e.target.value) + t.setAiTitleInput(e.target.value) + }} + maxLength={30} + size="small" + style={{ flex: 1 }} + /> + + +
+ {Array.from({ length: previewCount }, (_, i) => (
- updateVariantTitle(i, e.target.value)} + style={{ width: "100%" }} + value={previewTitles?.[i] || undefined} + onChange={(val) => updateVariantTitle(i, val || "")} + options={titleOptions} + filterOption={(inputValue, option) => { + const title = (option?.label || option?.value || "") as string + return title.toLowerCase().includes((inputValue || "").toLowerCase()) + }} />
))} diff --git a/apps/web/src/pages/generate/constants.ts b/apps/web/src/pages/generate/constants.ts index a63fb827d..c0c37d3bc 100644 --- a/apps/web/src/pages/generate/constants.ts +++ b/apps/web/src/pages/generate/constants.ts @@ -33,7 +33,8 @@ export const STEPS = [ { key: 2, label: "选择素材" }, { key: 3, label: "选择配音" }, { key: 4, label: "选择标题" }, - { key: 5, label: "选择封面" }, + { key: 5, label: "确认生成" }, + { key: 6, label: "选择封面" }, ] /* ── 批量生成限制 ── */ diff --git a/apps/web/src/pages/generate/generate.css b/apps/web/src/pages/generate/generate.css index 192755553..08e17899b 100644 --- a/apps/web/src/pages/generate/generate.css +++ b/apps/web/src/pages/generate/generate.css @@ -3278,3 +3278,167 @@ max-height: none; } } + +/* ============================================================ + 批量前端 Canvas 预览网格(Issue #1677 修正:纯前端实时预览) + ============================================================ */ +.xx-canvas-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 16px; +} + +.xx-canvas-grid-card { + border: 2px solid var(--border-primary, #e2e8f0); + border-radius: 12px; + overflow: hidden; + background: #000; + transition: border-color 0.2s ease; + min-width: 0; +} + +.xx-canvas-grid-card.selected { + border-color: var(--primary-color, #1677ff); + box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.15); +} + +.xx-canvas-grid-card-bar { + position: relative; + z-index: 2; + display: flex; + align-items: center; + padding: 6px 10px; + background: var(--bg-surface, #fff); + border-bottom: 1px solid var(--border-primary, #e2e8f0); +} + +.xx-canvas-grid-check { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 13px; + font-weight: 500; + color: var(--text-primary, #1a1a1a); + cursor: pointer; + user-select: none; +} + +.xx-canvas-grid-check input[type="checkbox"] { + width: 15px; + height: 15px; + cursor: pointer; + accent-color: var(--primary-color, #1677ff); +} + +/* ============================================================ + 批量标题:AI 一键生成行(Issue #1677) + ============================================================ */ +.xx-batch-ai-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + padding: 10px 12px; + margin-bottom: 12px; + background: var(--bg-secondary, #f7f8fa); + border: 1px dashed var(--border-primary, #d9d9d9); + border-radius: 10px; +} + +.xx-batch-ai-row .xx-form-field { + margin: 0; + flex: 1; + min-width: 140px; +} + +.xx-batch-titles { + display: flex; + flex-direction: column; + gap: 10px; +} + +/* ============================================================ + 第5步确认生成:批量渲染进度网格(Issue #1677) + ============================================================ */ +.xx-batch-gen-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 16px; +} + +.xx-batch-gen-card { + border: 1px solid var(--border-primary, #e2e8f0); + border-radius: 12px; + padding: 14px; + background: var(--bg-surface, #fff); + display: flex; + flex-direction: column; + gap: 10px; + min-width: 0; +} + +.xx-batch-gen-card.status-completed { + border-color: rgba(82, 196, 26, 0.4); + background: rgba(82, 196, 26, 0.04); +} + +.xx-batch-gen-card.status-failed { + border-color: rgba(239, 68, 68, 0.4); + background: rgba(239, 68, 68, 0.04); +} + +.xx-batch-gen-card-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.xx-batch-gen-card-title { + font-size: 14px; + font-weight: 600; + color: var(--text-primary, #1a1a1a); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.xx-batch-gen-card-body { + display: flex; + flex-direction: column; + gap: 8px; +} + +.xx-batch-gen-card-pct { + font-size: 13px; + color: var(--text-secondary, #666); + text-align: right; +} + +.xx-batch-gen-card-done { + font-size: 13px; + color: var(--success-color, #52c41a); + padding: 8px 0; +} + +.xx-batch-gen-card-failed { + display: flex; + flex-direction: column; + gap: 8px; + align-items: flex-start; +} + +.xx-batch-gen-card-err { + font-size: 13px; + color: var(--error-color, #ef4444); + line-height: 1.5; + word-break: break-word; +} + +/* ── 响应式:窄屏批量网格回退单列 ── */ +@media (max-width: 960px) { + .xx-canvas-grid, + .xx-batch-gen-grid { + grid-template-columns: 1fr; + } +} diff --git a/apps/web/src/pages/generate/hooks/generate-video/useGenerationPolling.ts b/apps/web/src/pages/generate/hooks/generate-video/useGenerationPolling.ts index 1661d9f81..cfa5be4fb 100644 --- a/apps/web/src/pages/generate/hooks/generate-video/useGenerationPolling.ts +++ b/apps/web/src/pages/generate/hooks/generate-video/useGenerationPolling.ts @@ -1,14 +1,28 @@ -import { useRef, useCallback } from "react" +import { useRef, useCallback, useState } from "react" import { message } from "antd" import axios from "axios" -import { getGenerationTask } from "@/api/tasks/tasks" +import { getGenerationTask, retryTask as retryGenerationTaskApi } from "@/api/tasks/tasks" import { getGenerationTaskResults } from "@/api/template-editor" import { safeExtractError } from "./errorUtils" +/** 批量生成时单个任务的实时状态(Issue #1677 第5步确认生成页) */ +export interface BatchTaskState { + taskId: string + /** 变体序号(0-based,与标题/封面数组对齐) */ + variantIndex: number + status: "running" | "completed" | "failed" + progress: number + error: string | null + /** 完成后的成片视频 */ + videos: unknown[] +} + interface UseGenerationPollingOptions { onProgress: (progress: number) => void onComplete: (videos: unknown[]) => void onFailed: (errorMsg: string) => void + /** 批量:单任务状态变化(第5步逐卡片展示) */ + onBatchTaskUpdate?: (taskId: string, patch: Partial) => void } /** 最大连续错误次数(仅对可重试错误),超过后终止轮询 */ @@ -17,20 +31,25 @@ const MAX_RETRYABLE_ERRORS = 10 const MAX_RESULTS_RETRIES = 3 /** - * 生成状态轮询 Hook(v3 — 支持批量多任务) + * 生成状态轮询 Hook(v4 — 批量任务独立状态 + 单任务重试) * * startPolling(taskId) 轮询单个任务; - * startPollingBatch(taskIds) 并行轮询 N 个任务,全部完成后聚合结果, - * 任一任务失败即整体失败(其余任务仍在后端继续,不影响)。 - * 进度为所有任务平均值。 + * startPollingBatch(tasks) 并行轮询 N 个任务: + * - 每个任务独立进度/状态/失败,通过 onBatchTaskUpdate 实时回传 + * - 全部成功才 onComplete(聚合视频按变体顺序);任一失败不影响其他任务继续 + * - retryTask(taskId) 单独重试失败任务(重新轮询,后端任务仍在跑则直接接续) */ export function useGenerationPolling({ onProgress, onComplete, onFailed, + onBatchTaskUpdate, }: UseGenerationPollingOptions) { const progressTimer = useRef[]>([]) const cancelledRef = useRef(false) + /** 批量任务上下文:taskId → 变体序号 */ + const batchContextRef = useRef>(new Map()) + const [, forceTick] = useState(0) const clearTimer = useCallback(() => { cancelledRef.current = true @@ -66,9 +85,21 @@ export function useGenerationPolling({ return safeExtractError(msg) } - /** 轮询单个任务,resolve 该任务的结果视频数组;失败时 reject(new Error(msg)) */ + /** + * 轮询单个任务。 + * - isBatch=true:状态变化通过 onBatchTaskUpdate 回传,不触发整体 onProgress/onComplete + * - resolve(videos) 成功;reject(Error) 失败 + */ const pollSingleTask = useCallback( - (taskId: string, runId: number, onTaskProgress?: (pct: number) => void): Promise => { + ( + taskId: string, + runId: number, + callbacks?: { + onTaskProgress?: (pct: number) => void + onTaskCompleted?: (videos: unknown[]) => void + onTaskFailed?: (msg: string) => void + }, + ): Promise => { return new Promise((resolve, reject) => { let consecutiveErrors = 0 let done = false @@ -85,9 +116,12 @@ export function useGenerationPolling({ const videos = await fetchResultsWithRetry(taskId) if (cancelledRef.current) return if (videos === null) { - reject(new Error("视频已生成,但获取结果列表失败,请稍后在任务列表查看")) + const msg = "视频已生成,但获取结果列表失败,请稍后在任务列表查看" + callbacks?.onTaskFailed?.(msg) + reject(new Error(msg)) return } + callbacks?.onTaskCompleted?.(videos) resolve(videos) return } @@ -98,14 +132,15 @@ export function useGenerationPolling({ task.error_info?.error_message || task.error_message || (task.status === "cancelled" ? "任务已取消" : "视频生成失败,请联系管理员或重试") - reject(new Error(safeExtractError(rawMsg))) + const msg = safeExtractError(rawMsg) + callbacks?.onTaskFailed?.(msg) + reject(new Error(msg)) return } const pct = Math.max(0, Math.min(99, Math.round(Number(task.progress) || 0))) - if (onTaskProgress) { - onTaskProgress(pct) - } else if (runId === 0) { + callbacks?.onTaskProgress?.(pct) + if (!callbacks && runId === 0) { onProgress(pct) } const timer = setTimeout(poll, 2000) @@ -116,13 +151,17 @@ export function useGenerationPolling({ const status = axios.isAxiosError(pollErr) ? pollErr.response?.status : undefined if (status && status >= 400 && status < 500) { done = true - reject(new Error(extractErrorMessage(pollErr, status))) + const msg = extractErrorMessage(pollErr, status) + callbacks?.onTaskFailed?.(msg) + reject(new Error(msg)) return } consecutiveErrors += 1 if (consecutiveErrors >= MAX_RETRYABLE_ERRORS) { done = true - reject(new Error("任务状态查询连续失败,请稍后在任务列表查看结果")) + const msg = "任务状态查询连续失败,请稍后在任务列表查看结果" + callbacks?.onTaskFailed?.(msg) + reject(new Error(msg)) return } const timer = setTimeout(poll, 3000) @@ -137,12 +176,12 @@ export function useGenerationPolling({ [onProgress, fetchResultsWithRetry], ) - /** 单任务轮询(兼容旧调用) */ + /** 单任务轮询(单视频,兼容旧调用) */ const startPolling = useCallback( (taskId: string) => { cancelledRef.current = false - const runId = 0 - pollSingleTask(taskId, runId) + batchContextRef.current.clear() + pollSingleTask(taskId, 0) .then((videos) => { if (cancelledRef.current) return onProgress(100) @@ -159,48 +198,114 @@ export function useGenerationPolling({ [pollSingleTask, onProgress, onComplete, onFailed], ) - /** 批量多任务轮询:全部完成后聚合结果;任一失败即整体失败 */ + /** + * 批量多任务轮询: + * - 每个任务独立进度/状态回传 onBatchTaskUpdate + * * 全部完成后按变体顺序聚合视频 onComplete + * - 部分失败:整体不 onFailed(第5步逐卡片展示失败+重试按钮);全部失败才 onFailed + */ const startPollingBatch = useCallback( - (taskIds: string[]) => { + (tasks: { taskId: string; variantIndex: number }[]) => { cancelledRef.current = false const runId = Date.now() const progressMap = new Map() + const resultMap = new Map() + const failureMap = new Map() + batchContextRef.current = new Map(tasks.map((t) => [t.taskId, t.variantIndex])) const reportAggregateProgress = () => { if (cancelledRef.current) return - const values = taskIds.map((id) => progressMap.get(id) ?? 0) + const values = tasks.map((t) => progressMap.get(t.taskId) ?? 0) const avg = Math.round(values.reduce((a, b) => a + b, 0) / Math.max(values.length, 1)) onProgress(Math.min(avg, 99)) } - const tasks = taskIds.map((taskId) => - pollSingleTask(taskId, runId, (pct) => { - progressMap.set(taskId, pct) - reportAggregateProgress() - }).then((videos) => { - progressMap.set(taskId, 100) - reportAggregateProgress() - return videos - }), - ) - - Promise.all(tasks) - .then((results) => { - if (cancelledRef.current) return + const checkAllSettled = () => { + if (resultMap.size + failureMap.size < tasks.length) return + if (resultMap.size === tasks.length) { onProgress(100) - const allVideos = results.flat() - onComplete(allVideos) - message.success(`全部 ${taskIds.length} 个视频生成完成!`) + const ordered = tasks.map((t) => resultMap.get(t.taskId) || []).flat() + onComplete(ordered) + message.success(`全部 ${tasks.length} 个视频生成完成!`) + } else if (resultMap.size > 0) { + // 部分失败:成功的视频聚合进成片列表(可进封面),失败卡片带重试按钮 + onProgress(100) + const ordered = tasks + .filter((t) => resultMap.has(t.taskId)) + .map((t) => resultMap.get(t.taskId) || []) + .flat() + onComplete(ordered) + message.warning( + `${failureMap.size} 个视频生成失败,可点击卡片上的「重试此视频」,成功的视频可先进入下一步`, + ) + } else { + const firstMsg = failureMap.get(tasks[0].taskId) || "全部视频生成失败" + onFailed(firstMsg) + } + } + + tasks.forEach(({ taskId, variantIndex }) => { + onBatchTaskUpdate?.(taskId, { + taskId, + variantIndex, + status: "running", + progress: 0, + error: null, + videos: [], }) - .catch((err: Error) => { - if (cancelledRef.current) return - console.error("[批量生成失败]", err.message) - onFailed(err.message) - message.error(err.message) + pollSingleTask(taskId, runId, { + onTaskProgress: (pct) => { + progressMap.set(taskId, pct) + onBatchTaskUpdate?.(taskId, { status: "running", progress: pct }) + reportAggregateProgress() + }, + onTaskCompleted: (videos) => { + progressMap.set(taskId, 100) + resultMap.set(taskId, videos) + onBatchTaskUpdate?.(taskId, { status: "completed", progress: 100, videos }) + reportAggregateProgress() + checkAllSettled() + }, + onTaskFailed: (msg) => { + failureMap.set(taskId, msg) + onBatchTaskUpdate?.(taskId, { status: "failed", error: msg }) + checkAllSettled() + }, + }).catch(() => { + // 失败已在 onTaskFailed 处理,这里吞掉 Promise rejection }) + }) }, - [pollSingleTask, onProgress, onComplete, onFailed], + [pollSingleTask, onProgress, onComplete, onFailed, onBatchTaskUpdate], ) - return { startPolling, startPollingBatch, clearTimer } + /** 单独重试失败任务(第5步卡片「重试此视频」):先调后端重试接口,再轮询 */ + const retryTask = useCallback( + async (taskId: string) => { + if (cancelledRef.current) cancelledRef.current = false + const variantIndex = batchContextRef.current.get(taskId) ?? 0 + onBatchTaskUpdate?.(taskId, { status: "running", progress: 0, error: null, videos: [] }) + try { + await retryGenerationTaskApi(taskId) + } catch (err) { + // 后端不支持重试或任务不可重试:直接重新轮询(任务可能已被自动恢复) + console.warn("[重试任务接口调用失败,改为直接轮询]", err) + } + pollSingleTask(taskId, Date.now(), { + onTaskProgress: (pct) => onBatchTaskUpdate?.(taskId, { status: "running", progress: pct }), + onTaskCompleted: (videos) => { + onBatchTaskUpdate?.(taskId, { status: "completed", progress: 100, videos }) + message.success(`视频 ${variantIndex + 1} 重试成功`) + }, + onTaskFailed: (msg) => onBatchTaskUpdate?.(taskId, { status: "failed", error: msg }), + }).catch(() => { + /* 失败已在回调处理 */ + }) + forceTick((n) => n + 1) + return variantIndex + }, + [pollSingleTask, onBatchTaskUpdate], + ) + + return { startPolling, startPollingBatch, retryTask, clearTimer } } diff --git a/apps/web/src/pages/generate/hooks/useBatchPreview.ts b/apps/web/src/pages/generate/hooks/useBatchPreview.ts deleted file mode 100644 index e8aacae52..000000000 --- a/apps/web/src/pages/generate/hooks/useBatchPreview.ts +++ /dev/null @@ -1,285 +0,0 @@ -/** - * 批量服务器预览 Hook(Issue #1677 多视频批量生成) - * - * 核心职责: - * 1. 调用 POST /generation/preview(preview_count=N)一次创建 N 个独立变体任务 - * 2. 对每个变体 task_id 分别轮询 GET /generation/preview/{task_id} - * 3. 返回每个变体的状态/进度/视频URL,供网格播放器展示 - * - * N=1 时不启用(走前端 Canvas 实时预览,零回归); - * N>1 时进入标题页自动触发;素材/配音等配置变化后重新触发。 - */ -import { useState, useCallback, useRef, useEffect } from "react" -import { createPreview, getPreviewStatus } from "@/api/generation/preview" -import type { CreatePreviewRequest } from "@/api/generation/types" - -export type VariantPreviewStatus = "loading" | "ready" | "failed" - -export interface VariantPreview { - /** 变体序号(0-based) */ - index: number - taskId: string - status: VariantPreviewStatus - progress: number - videoUrl: string | null - error: string | null -} - -interface UseBatchPreviewOptions { - /** 是否启用(仅 previewCount>1 且在标题页时启用) */ - enabled: boolean - /** 构建预览请求参数(每次触发时调用,获取最新配置) */ - buildRequest: () => CreatePreviewRequest - /** 批量预览任务创建成功回调(回传变体 taskId 列表与 source_edit_plan_id) */ - onPreviewTasksCreated?: (taskIds: string[], sourceEditPlanId?: string) => void -} - -interface UseBatchPreviewReturn { - variants: VariantPreview[] - /** 整体状态:loading=任一进行中,ready=全部完成,failed=有失败 */ - status: "idle" | "loading" | "ready" | "partial_failed" | "failed" - /** 总进度 0-100(各变体平均值) */ - progress: number - /** 失败的变体数量 */ - failedCount: number - /** 手动重新触发 */ - trigger: () => void -} - -const POLL_INTERVAL = 2000 -const POLL_TIMEOUT = 180_000 -const MAX_NETWORK_RETRIES = 2 - -/** - * 对配置参数做指纹,用于检测配置是否变化(标题文字/样式变化不触发重渲染,仅CSS浮层叠加) - */ -function buildFingerprint(req: CreatePreviewRequest): string { - // 不含 titles/title_config:标题文字与样式由 CSS 浮层实时叠加,变化不触发重渲染 - return JSON.stringify({ - t: req.template_id, - a: [...(req.asset_ids || [])].sort(), - r: req.video_ratio, - v: req.voice_library_id, - vs: req.voice_library_ids, - pc: req.preview_count, - b: req.bgm_config, - }) -} - -export function useBatchPreview({ - enabled, - buildRequest, - onPreviewTasksCreated, -}: UseBatchPreviewOptions): UseBatchPreviewReturn { - const [variants, setVariants] = useState([]) - const [status, setStatus] = useState<"idle" | "loading" | "ready" | "partial_failed" | "failed">( - "idle", - ) - const requestSeqRef = useRef(0) - const pollTimersRef = useRef[]>([]) - const timeoutTimerRef = useRef | null>(null) - const mountedRef = useRef(true) - - const buildRequestRef = useRef(buildRequest) - buildRequestRef.current = buildRequest - const onCreatedRef = useRef(onPreviewTasksCreated) - onCreatedRef.current = onPreviewTasksCreated - - const clearTimers = useCallback(() => { - pollTimersRef.current.forEach((t) => clearTimeout(t)) - pollTimersRef.current = [] - if (timeoutTimerRef.current) { - clearTimeout(timeoutTimerRef.current) - timeoutTimerRef.current = null - } - }, []) - - useEffect(() => { - mountedRef.current = true - return () => { - mountedRef.current = false - clearTimers() - } - }, [clearTimers]) - - /** 更新单个变体状态 */ - const patchVariant = useCallback((taskId: string, patch: Partial) => { - setVariants((prev) => prev.map((v) => (v.taskId === taskId ? { ...v, ...patch } : v))) - }, []) - - /** 轮询单个变体任务 */ - const pollVariant = useCallback( - async (taskId: string, seq: number, retries = 0) => { - if (seq !== requestSeqRef.current || !mountedRef.current) return - try { - const st = await getPreviewStatus(taskId) - if (seq !== requestSeqRef.current || !mountedRef.current) return - - if (st.status === "completed" && st.video_url) { - patchVariant(taskId, { - status: "ready", - videoUrl: st.video_url, - progress: 100, - error: null, - }) - return - } - if (st.status === "failed" || st.status === "cancelled") { - patchVariant(taskId, { - status: "failed", - error: - st.status === "cancelled" ? "预览任务已取消" : st.error_message || "预览渲染失败", - }) - return - } - if (typeof st.progress === "number") { - patchVariant(taskId, { progress: Math.round(st.progress) }) - } - const timer = setTimeout(() => pollVariant(taskId, seq), POLL_INTERVAL) - pollTimersRef.current.push(timer) - } catch (err) { - if (seq !== requestSeqRef.current || !mountedRef.current) return - if (retries < MAX_NETWORK_RETRIES) { - console.warn(`[BatchPreview] 变体 ${taskId} 轮询网络错误,第 ${retries + 1} 次重试`, err) - const timer = setTimeout(() => pollVariant(taskId, seq, retries + 1), POLL_INTERVAL * 2) - pollTimersRef.current.push(timer) - } else { - patchVariant(taskId, { status: "failed", error: "网络错误,无法获取预览状态" }) - } - } - }, - [patchVariant], - ) - - /** 创建批量预览任务并开始轮询 */ - const trigger = useCallback(() => { - if (!enabled) return - const request = buildRequestRef.current() - if (!request.template_id || !request.asset_ids?.length) return - const count = request.preview_count && request.preview_count > 1 ? request.preview_count : 0 - if (!count) return - - clearTimers() - const seq = ++requestSeqRef.current - setStatus("loading") - setVariants( - Array.from({ length: count }, (_, i) => ({ - index: i, - taskId: "", - status: "loading" as const, - progress: 0, - videoUrl: null, - error: null, - })), - ) - - createPreview(request) - .then((resp) => { - if (seq !== requestSeqRef.current || !mountedRef.current) return - const items = resp.items || [] - const taskIds = items.map((it) => it.task_id).filter(Boolean) - if (taskIds.length === 0) { - setStatus("failed") - setVariants((prev) => - prev.map((v) => ({ ...v, status: "failed", error: "未创建预览任务" })), - ) - return - } - onCreatedRef.current?.(taskIds, resp.source_edit_plan_id) - - // 用返回的 task_id 填充变体(按 variant_index 对齐) - setVariants((prev) => - prev.map((v) => { - const item = items.find((it) => it.variant_index === v.index) || items[v.index] - return item ? { ...v, taskId: item.task_id } : v - }), - ) - - // 超时保护 - timeoutTimerRef.current = setTimeout(() => { - if (seq !== requestSeqRef.current || !mountedRef.current) return - setVariants((prev) => - prev.map((v) => - v.status === "loading" - ? { ...v, status: "failed", error: "预览渲染超时,请重试" } - : v, - ), - ) - }, POLL_TIMEOUT) - - // 分别轮询每个变体 - items.forEach((item) => { - if (item.task_id) pollVariant(item.task_id, seq) - }) - }) - .catch((err: unknown) => { - if (seq !== requestSeqRef.current || !mountedRef.current) return - console.error("[BatchPreview] 创建批量预览失败:", err) - const errData = (err as { response?: { data?: { detail?: string; message?: string } } }) - ?.response?.data - setStatus("failed") - setVariants((prev) => - prev.map((v) => ({ - ...v, - status: "failed", - error: errData?.detail || errData?.message || "预览任务创建失败,请重试", - })), - ) - }) - }, [enabled, clearTimers, pollVariant]) - - /* ── 自动触发 + 配置变更检测 ── */ - const request = enabled ? buildRequest() : null - const currentFingerprint = request - ? request.template_id && request.asset_ids?.length && (request.preview_count || 1) > 1 - ? buildFingerprint(request) - : "" - : "" - - const didInitRef = useRef(false) - useEffect(() => { - if (!enabled || !currentFingerprint) { - didInitRef.current = false - requestSeqRef.current += 1 - clearTimers() - setStatus("idle") - setVariants([]) - return - } - if (!didInitRef.current) { - didInitRef.current = true - trigger() - } - }, [enabled, currentFingerprint, trigger, clearTimers]) - - // 配置变更(素材/配音/数量)→ 重新渲染;标题文字变化不触发(CSS浮层实时叠加) - const prevFingerprintRef = useRef(currentFingerprint) - useEffect(() => { - if (!enabled || !currentFingerprint) return - const prev = prevFingerprintRef.current - prevFingerprintRef.current = currentFingerprint - if (!prev || prev === currentFingerprint) return - trigger() - }, [enabled, currentFingerprint, trigger]) - - /* ── 派生状态 ── */ - const progress = - variants.length > 0 - ? Math.round(variants.reduce((sum, v) => sum + v.progress, 0) / variants.length) - : 0 - const failedCount = variants.filter((v) => v.status === "failed").length - const readyCount = variants.filter((v) => v.status === "ready").length - - useEffect(() => { - if (status !== "loading" || variants.length === 0) return - if (readyCount === variants.length) { - setStatus("ready") - } else if (readyCount + failedCount === variants.length && failedCount > 0) { - setStatus(failedCount === variants.length ? "failed" : "partial_failed") - } - }, [variants, status, readyCount, failedCount]) - - return { variants, status, progress, failedCount, trigger } -} - -export default useBatchPreview diff --git a/apps/web/src/pages/generate/hooks/useGenerateVideo.ts b/apps/web/src/pages/generate/hooks/useGenerateVideo.ts index 6f77188ee..2d7a6d0d9 100755 --- a/apps/web/src/pages/generate/hooks/useGenerateVideo.ts +++ b/apps/web/src/pages/generate/hooks/useGenerateVideo.ts @@ -2,13 +2,13 @@ * 视频生成 Hook * 封装视频生成的核心逻辑、状态管理、轮询等 */ -import { useState, useCallback } from "react" +import { useState, useCallback, useEffect } from "react" import { message } from "antd" import { type GeneratedVideo, getEditPlanClips, createClipsFromAssets } from "@/api/template-editor" import { createGenerationTask } from "@/api/tasks/tasks" import type { UseGenerateVideoProps } from "./generate-video/types" import { getGenerationPhase } from "./generate-video/phase" -import { useGenerationPolling } from "./generate-video/useGenerationPolling" +import { useGenerationPolling, type BatchTaskState } from "./generate-video/useGenerationPolling" import { validateGenerateInputs } from "./generate-video/buildPayload" import { calculateResolution } from "../utils/calculateResolution" import { extractBackendError, translateError } from "./generate-video/errorUtils" @@ -22,6 +22,32 @@ export function useGenerateVideo(props: UseGenerateVideoProps) { const [generated, setGenerated] = useState(false) const [generateError, setGenerateError] = useState(null) const [generatedVideos, setGeneratedVideos] = useState([]) + /** 批量模式:每个正式生成任务的独立状态(第5步逐卡片展示) */ + const [batchTasks, setBatchTasks] = useState([]) + + const handleBatchTaskUpdate = useCallback((taskId: string, patch: Partial) => { + setBatchTasks((prev) => { + const list = prev || [] + const idx = list.findIndex((t) => t.taskId === taskId) + if (idx === -1) { + return [ + ...list, + { + taskId, + variantIndex: patch.variantIndex ?? 0, + status: "running", + progress: 0, + error: null, + videos: [], + ...patch, + }, + ] + } + const next = [...list] + next[idx] = { ...next[idx], ...patch } + return next + }) + }, []) const handleProgress = useCallback((p: number) => setProgress(p), []) const handleComplete = useCallback( @@ -29,6 +55,19 @@ export function useGenerateVideo(props: UseGenerateVideoProps) { setGenerating(false) setGenerated(true) setGeneratedVideos(videos as GeneratedVideo[]) + // 批量:成功任务的 videos 已通过 onBatchTaskUpdate 写入,这里同步兜底 + setBatchTasks((prev) => + (prev || []).map((t) => + t.status === "completed" && t.videos.length === 0 + ? { + ...t, + videos: (videos as GeneratedVideo[]).filter( + (v) => v.generation_task_id === t.taskId, + ), + } + : t, + ), + ) onGenerationSuccess?.() }, [onGenerationSuccess], @@ -38,10 +77,30 @@ export function useGenerateVideo(props: UseGenerateVideoProps) { setGenerateError(errorMsg) }, []) - const { startPolling, startPollingBatch, clearTimer } = useGenerationPolling({ + /* 批量:任务状态变化时聚合已完成成片(含失败重试成功后补入), + 按变体索引排序,供步骤6封面按勾选顺序逐个取视频 */ + useEffect(() => { + if (batchTasks.length === 0) return + const byVariant = new Map() + batchTasks.forEach((t) => { + if (t.status === "completed" && t.videos && t.videos.length > 0) { + byVariant.set(t.variantIndex, t.videos[0] as GeneratedVideo) + } + }) + const ordered = [...byVariant.entries()].sort((a, b) => a[0] - b[0]).map(([, v]) => v) + setGeneratedVideos((prev) => { + if (prev.length === ordered.length && prev.every((v, i) => v.id === ordered[i].id)) { + return prev + } + return ordered + }) + }, [batchTasks]) + + const { startPolling, startPollingBatch, retryTask, clearTimer } = useGenerationPolling({ onProgress: handleProgress, onComplete: handleComplete, onFailed: handleFailed, + onBatchTaskUpdate: handleBatchTaskUpdate, }) /* ── 生成视频 ── @@ -57,6 +116,7 @@ export function useGenerateVideo(props: UseGenerateVideoProps) { setProgress(0) setGenerated(false) setGenerateError(null) + setBatchTasks([]) clearTimer() try { @@ -170,7 +230,8 @@ export function useGenerateVideo(props: UseGenerateVideoProps) { throw new Error("创建任务成功但未返回任务 ID,请稍后在任务列表查看") } if (taskIds.length > 1) { - startPollingBatch(taskIds) + // 批量:任务按创建顺序与勾选变体一一对应(后端按 count 顺序创建) + startPollingBatch(taskIds.map((taskId, i) => ({ taskId, variantIndex: indexes[i] ?? i }))) } else { startPolling(taskIds[0]) } @@ -196,6 +257,14 @@ export function useGenerateVideo(props: UseGenerateVideoProps) { generate() }, [generate]) + /** 第5步:单独重试某个失败任务 */ + const retryBatchTask = useCallback( + (taskId: string) => { + retryTask(taskId) + }, + [retryTask], + ) + const dismissError = useCallback(() => { setGenerateError(null) }, []) @@ -240,6 +309,8 @@ export function useGenerateVideo(props: UseGenerateVideoProps) { generatedVideos, generate, retry, + retryBatchTask, + batchTasks, dismissError, download, share, diff --git a/apps/web/src/pages/generate/hooks/useStepNavigation.ts b/apps/web/src/pages/generate/hooks/useStepNavigation.ts index d7e59bd58..12e8c51fd 100644 --- a/apps/web/src/pages/generate/hooks/useStepNavigation.ts +++ b/apps/web/src/pages/generate/hooks/useStepNavigation.ts @@ -1,6 +1,11 @@ /** - * GeneratePage 步骤导航(Issue #1677 改造后 5 步) - * 步骤:模板(1) → 素材(2) → 配音(3) → 标题+预览+确认生成(4) → 封面(5) + * GeneratePage 步骤导航(Issue #1677 修正:固定 6 步,单视频与批量一致) + * 步骤:模板(1) → 素材(2) → 配音(3) → 标题(4) → 确认生成(5) → 封面(6) + * + * - 步骤4底部按钮是「确认生成视频/确认生成 N 个视频」(由 GenerateStepActions 调 + * onConfirmGenerate),创建成功后跳转步骤5;本 hook 的 goNext 只负责 1→2→3→4 + * 和 5→6 的「下一步」。 + * - 步骤5(确认生成进度页):渲染全部完成(generated)后「下一步」解锁进封面。 */ import { message } from "antd" import type { TitleSettings } from "../types" @@ -13,14 +18,8 @@ export interface UseStepNavigationOptions { selectedMaterials: string[] smartSelectedIds: string[] titleSettings: TitleSettings - /** 预览是否已就绪(单视频=前端预览素材已加载;批量=服务器预览全部完成) */ - previewReady: boolean - /** 是否已完成视频生成(步骤4确认生成后才能进入封面) */ + /** 是否已完成视频生成(步骤5全部渲染完成后才能进入封面) */ generated: boolean - /** 批量模式下每个变体的标题 */ - previewTitles: string[] - /** 批量模式勾选的变体数 */ - selectedCount: number /** Step1 点下一步时弹出数量选择弹窗 */ onOpenCountModal: () => void } @@ -38,10 +37,7 @@ export const useStepNavigation = (options: UseStepNavigationOptions): UseStepNav materialMode, selectedMaterials, smartSelectedIds, - previewReady, generated, - previewTitles, - selectedCount, onOpenCountModal, } = options @@ -63,27 +59,14 @@ export const useStepNavigation = (options: UseStepNavigationOptions): UseStepNav message.warning("请先进行智能匹配并选择素材") return } - // Step4(标题+预览+确认生成):标题必填 + 预览必须已加载 - if (currentStep === 4) { - const allTitlesFilled = previewTitles.every((t) => t && t.trim()) - if (!allTitlesFilled) { - message.warning("请为每个视频输入标题") - return - } - if (selectedCount === 0) { - message.warning("请至少勾选一个视频") - return - } - if (!previewReady) { - message.warning("预览视频正在加载,请稍候") - return - } + // 步骤5(确认生成):全部渲染完成后才能下一步进封面 + if (currentStep === 5) { if (!generated) { - message.warning("请先点击「确认生成视频」完成渲染") + message.warning("视频还在渲染中,请等待生成完成") return } } - if (currentStep < 5) { + if (currentStep < 6) { setCurrentStep((s) => s + 1) } } diff --git a/apps/web/src/test/pages/generate/smoke.test.tsx b/apps/web/src/test/pages/generate/smoke.test.tsx index 90da8ddc3..9e58e0c6b 100755 --- a/apps/web/src/test/pages/generate/smoke.test.tsx +++ b/apps/web/src/test/pages/generate/smoke.test.tsx @@ -20,7 +20,8 @@ import "@/pages/generate/components/Step2MaterialSelect" import "@/pages/generate/components/Step4TitleSettings" import "@/pages/generate/components/Step5VoiceSelect" import "@/pages/generate/components/Step3VoiceWithMode" -import "@/pages/generate/components/ServerPreviewGrid" +import "@/pages/generate/components/CanvasPreviewGrid" +import "@/pages/generate/components/BatchGenerationGrid" import "@/pages/generate/components/PreviewCountModal" import "@/pages/generate/components/PreviewVideoPanel" import "@/pages/generate/components/GenerateResultPanel" @@ -48,7 +49,6 @@ describe("GeneratePage module smoke test", () => { }) }) import "@/pages/generate/hooks/useGenerateVideo" -import "@/pages/generate/hooks/useBatchPreview" import "@/pages/generate/hooks/useBatchCovers" import "@/pages/generate/hooks/usePreviewAssets" import "@/pages/generate/hooks/useSegmentScheduler"