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 && (