diff --git a/apps/web/src/api/generation/types.ts b/apps/web/src/api/generation/types.ts
index 6b877681e..61ae5749c 100755
--- a/apps/web/src/api/generation/types.ts
+++ b/apps/web/src/api/generation/types.ts
@@ -33,15 +33,36 @@ export interface CreatePreviewRequest {
preset_id?: string
volume?: number
}
+ /** 批量预览数量(1~10),默认1。N>1 时返回 N 个独立变体任务 */
+ preview_count?: number
+ /** 各变体独立标题文字:长度1=共用,长度=preview_count=独立,空数组=使用 title_config.text */
+ titles?: string[]
+ /** 各变体独立配音素材库ID:长度1=共用,长度=preview_count=独立,空数组=回退 voice_library_id */
+ voice_library_ids?: string[]
+ /** 各变体独立封面URL:长度1=共用,长度=preview_count=独立(预览阶段通常为空) */
+ cover_urls?: string[]
}
-/** 创建预览任务响应 */
-export interface CreatePreviewResponse {
+/** 单个预览变体任务 */
+export interface PreviewVariantItem {
task_id: string
- status: PreviewStatus
+ status: string
+ progress: number
is_preview: boolean
+ variant_index: number
resolution: string
- created_at: string
+ video_url: string
+ duration: number
+ error_message: string
+ title_text: string
+ voice_library_id: string
+ created_at?: string | null
+}
+
+/** 创建预览任务响应(单变体,preview_count=1 时 items 长度为1) */
+export interface CreatePreviewResponse {
+ items: PreviewVariantItem[]
+ total: number
/** 后端自动关联的编辑计划 ID(用于 fallback 路径传递 source_edit_plan_id) */
source_edit_plan_id?: string
}
diff --git a/apps/web/src/api/tasks/types.ts b/apps/web/src/api/tasks/types.ts
index 42e41995b..246c8debe 100644
--- a/apps/web/src/api/tasks/types.ts
+++ b/apps/web/src/api/tasks/types.ts
@@ -92,6 +92,14 @@ export interface CreateGenerationTaskRequest {
preset_id?: string
volume?: number
}
+ /** 批量生成数量(1~10),默认1。不传=单条旧逻辑 */
+ count?: number
+ /** 各变体独立标题文字:长度1=共用,长度=count=独立,空数组=使用 title_config/custom_title */
+ titles?: string[]
+ /** 各变体独立配音素材库ID:长度1=共用,长度=count=独立,空数组=回退 voice_library_id */
+ voice_library_ids?: string[]
+ /** 各变体独立封面URL:长度1=共用,长度=count=独立,空数组=回退 cover_url */
+ cover_urls?: string[]
}
/** 单个生成任务详情(对齐后端 GenerationTaskResponse) */
diff --git a/apps/web/src/pages/generate/GeneratePage.tsx b/apps/web/src/pages/generate/GeneratePage.tsx
index db681da43..57fad236c 100644
--- a/apps/web/src/pages/generate/GeneratePage.tsx
+++ b/apps/web/src/pages/generate/GeneratePage.tsx
@@ -1,12 +1,11 @@
/**
- * 智能剪辑页面 — 前端实时预览架构
- * 6 步向导:选择模板 → 素材 → 配音 → 标题(含预览) → 确认生成 → 选择封面
+ * 智能剪辑页面(Issue #1677 多视频批量生成)
+ * 5 步向导:选择模板(弹数量) → 素材 → 配音 → 标题(预览+确认生成) → 封面
*
* 架构:
- * - 步骤 4 右侧显示 FrontendPreviewPlayer 实时预览
- * - 步骤 5 右侧内联播放生成中的/最终视频
- * - 步骤 6 封面从最终成片中智能选帧(MediaKit)
- * - 点"确认生成"时调用 createGenerationTask 创建一次服务器渲染任务
+ * - N=1:前端 Canvas 实时预览(FrontendPreviewPlayer),零回归
+ * - N>1:服务器批量预览(POST /generation/preview?preview_count=N),
+ * N 个变体分别轮询,网格展示、独立可播放、CSS 标题浮层实时叠加、勾选批量生成
*/
import React, { useMemo, useState, useEffect, useRef, useCallback } from "react"
import { message } from "antd"
@@ -17,16 +16,20 @@ 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 PreviewCountModal from "./components/PreviewCountModal"
import GenerateStepsBar from "./components/GenerateStepsBar"
import GenerateStepContent from "./components/GenerateStepContent"
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 = () => {
@@ -53,10 +56,8 @@ const GeneratePage: React.FC = () => {
selectedVoice,
setSelectedVoice,
voiceMode,
- setVoiceMode,
selectedClonedVoice,
- setSelectedClonedVoice,
- presetVoices,
+
cloneModalOpen,
setCloneModalOpen,
videoRatio,
@@ -72,8 +73,51 @@ const GeneratePage: React.FC = () => {
setStoredSourceEditPlanId,
serverClips,
setServerClips,
+ previewCount,
+ setPreviewCount,
+ previewTitles,
+ setPreviewTitles,
+ voiceModePerVideo,
+ setVoiceModePerVideo,
+ voiceLibraryIds,
+ setVoiceLibraryIds,
+ previewCovers,
+ setPreviewCovers,
+ selectedVariantIds,
+ setSelectedVariantIds,
} = formState
+ const isBatch = previewCount > 1
+
+ /* ── 配音选择同步:共用配音 ↔ 变体数组 ── */
+ // 触发场景:①共用配音变化 ②批量模式进入/退出 ③独立→共用切换(需把所有变体刷成共用配音)
+ // 独立模式下:仅同步变体[0](其选择器绑定共用配音),用户单独选择的其他变体不覆盖
+ const prevVoiceSyncRef = useRef({
+ voice: selectedVoice,
+ batch: isBatch,
+ perVideo: voiceModePerVideo,
+ })
+ useEffect(() => {
+ const prev = prevVoiceSyncRef.current
+ const voiceChanged = prev.voice !== selectedVoice
+ const modeChanged = prev.batch !== isBatch || prev.perVideo !== voiceModePerVideo
+ prevVoiceSyncRef.current = { voice: selectedVoice, batch: isBatch, perVideo: voiceModePerVideo }
+ if (!voiceChanged && !modeChanged) return
+ if (!isBatch) return
+ if (!voiceModePerVideo) {
+ // 共用模式(含刚从独立切回):所有变体跟随共用配音,未选择的补默认值
+ setVoiceLibraryIds((prevIds) => (prevIds || []).map((id) => id || selectedVoice))
+ } else if (voiceChanged) {
+ // 独立模式下共用配音变化:仅同步变体[0](与共用选择器绑定),其余不覆盖
+ setVoiceLibraryIds((prevIds) =>
+ (prevIds || []).map((id, i) => (i === 0 ? selectedVoice : id)),
+ )
+ }
+ }, [selectedVoice, isBatch, voiceModePerVideo, setVoiceLibraryIds])
+
+ /* ── 数量选择弹窗 ── */
+ const [countModalOpen, setCountModalOpen] = useState(false)
+
/* ── 标题样式回调 ── */
const styleUpdaters = useTitleStyleUpdaters({
titleSettings,
@@ -127,7 +171,7 @@ const GeneratePage: React.FC = () => {
}, [selectedVoice, selectedClonedVoice, titleSettings.title, voiceMaterials])
/* ── 克隆声音 ── */
- const { clones: clonedVoices, addClone, hasProcessing } = useCloneProgress()
+ const { addClone } = useCloneProgress()
const handleCloneSuccess = (voice: VoiceClone) => {
addClone(voice)
@@ -156,19 +200,95 @@ const GeneratePage: React.FC = () => {
[bgm, currentTemplate],
)
- /* ── 加载素材详情(供前端预览播放器使用 + 配音时长校验) ── */
+ /* ── 加载素材详情(供前端预览播放器使用) ── */
const previewAssetsEnabled = previewAssetIds.length > 0
const { assets: previewAssets, ready: previewAssetsReady } = usePreviewAssets(
previewAssetIds,
previewAssetsEnabled,
)
- /* ── 预览就绪:素材已加载,且有模板 ── */
- const previewReady = useMemo(
+ /* ── 预览就绪 ── */
+ 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
+
+ /* ── 勾选变体 ── */
+ const toggleVariantSelect = useCallback(
+ (index: number) => {
+ setSelectedVariantIds((prev) => {
+ const list = prev || []
+ return list.includes(index) ? list.filter((i) => i !== index) : [...list, index].sort()
+ })
+ },
+ [setSelectedVariantIds],
+ )
+
/* ── 视频生成核心逻辑 ── */
const {
generating,
@@ -199,16 +319,61 @@ const GeneratePage: React.FC = () => {
sourceEditPlanId: storedSourceEditPlanId || sourceEditPlanId,
previewTaskId,
bgmConfig,
+ previewCount,
+ variantTitles: previewTitles,
+ variantVoiceLibraryIds: voiceLibraryIds,
+ voiceModePerVideo,
+ variantCoverUrls: previewCovers,
+ selectedVariantIndexes: isBatch ? selectedVariantIds : undefined,
onGenerationSuccess: () => {
setPreviewTaskId(null)
setStoredSourceEditPlanId(null)
},
})
- /* ── 步骤4「确认生成视频」:校验标题/预览 → 创建最终渲染任务 → 成功后进入步骤5 ── */
+ /* ── 数量弹窗确认:设置数量 + 同步批量数组长度 + 进入步骤2 ── */
+ const handleCountConfirm = useCallback(
+ (count: number) => {
+ setPreviewCount(count)
+ setCountModalOpen(false)
+ // 同步批量数组长度
+ setPreviewTitles((prev) => {
+ const list = prev || []
+ const base = list[0] || titleSettings.title || ""
+ return Array.from({ length: count }, (_, i) => list[i] ?? (i === 0 ? base : ""))
+ })
+ setVoiceLibraryIds((prev) => {
+ const list = prev || []
+ return Array.from({ length: count }, (_, i) => list[i] ?? selectedVoice ?? "")
+ })
+ setPreviewCovers((prev) => {
+ const list = prev || []
+ return Array.from({ length: count }, (_, i) => list[i] ?? "")
+ })
+ setSelectedVariantIds(Array.from({ length: count }, (_, i) => i))
+ setCurrentStep(2)
+ },
+ [
+ setPreviewCount,
+ setPreviewTitles,
+ setVoiceLibraryIds,
+ setPreviewCovers,
+ setSelectedVariantIds,
+ setCurrentStep,
+ titleSettings.title,
+ selectedVoice,
+ ],
+ )
+
+ /* ── 步骤4「确认生成视频」 ── */
const handleConfirmGenerate = useCallback(async () => {
- if (!titleSettings.title.trim()) {
- message.warning("请选择或输入标题")
+ // 标题校验
+ if (previewTitles.some((t) => !t?.trim())) {
+ message.warning("请为每个视频输入标题")
+ return
+ }
+ if (isBatch && selectedVariantIds.length === 0) {
+ message.warning("请至少勾选一个视频")
return
}
if (!previewReady) {
@@ -216,10 +381,18 @@ const GeneratePage: React.FC = () => {
return
}
const ok = await handleGenerate()
- if (ok) {
+ if (ok && !isBatch) {
setCurrentStep(5)
}
- }, [titleSettings.title, previewReady, handleGenerate, setCurrentStep])
+ // 批量模式停留在步骤4,右侧网格显示生成进度,完成后点"下一步"进封面
+ }, [
+ isBatch,
+ selectedVariantIds.length,
+ previewReady,
+ previewTitles,
+ handleGenerate,
+ setCurrentStep,
+ ])
/* ── 步骤导航 ── */
const { goNext, goPrev } = useStepNavigation({
@@ -232,11 +405,24 @@ const GeneratePage: React.FC = () => {
titleSettings,
previewReady,
generated,
+ previewTitles,
+ selectedCount: isBatch ? selectedVariantIds.length : 1,
+ onOpenCountModal: () => setCountModalOpen(true),
})
- /* ── 最终成片(步骤5/6 右侧播放) ── */
+ /* ── 最终成片(单视频右侧播放) ── */
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])
+
/* ================================================================
渲染
================================================================ */
@@ -247,8 +433,142 @@ const GeneratePage: React.FC = () => {
-
- {/* ════ 左侧:表单区 ════ */}
+
+ {/* ════ 步骤4:左侧预览大区域 ════ */}
+ {currentStep === 4 && !!currentTemplate && (
+
+ {!isBatch ? (
+ /* 单视频:前端 Canvas 实时预览(与旧版一致) */
+
0}
+ serverClips={serverClips}
+ voiceAudioUrl={previewVoiceAudioUrl || undefined}
+ titleSettings={{
+ title: titleSettings.title,
+ 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,
+ }}
+ onTitlePositionChange={styleUpdaters.updateTitlePosition}
+ />
+ ) : (
+ /* 批量:服务器预览网格 */
+
+
+
🎬 {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 封面 ════ */}
{
selectedVoice={selectedVoice}
onSelectedVoiceChange={setSelectedVoice}
onServerClipsChange={setServerClips}
- voiceMode={voiceMode}
- onVoiceModeChange={setVoiceMode}
- selectedClonedVoice={selectedClonedVoice}
- onSelectedClonedVoiceChange={setSelectedClonedVoice}
- clonedVoices={clonedVoices}
- addClone={addClone}
- hasProcessing={hasProcessing}
- cloneModalOpen={cloneModalOpen}
- onCloneModalOpenChange={setCloneModalOpen}
generating={generating}
generated={generated}
generateError={generateError}
@@ -296,7 +607,16 @@ const GeneratePage: React.FC = () => {
generatedVideos={generatedVideos}
onRetry={handleRetryGenerate}
onDismissError={handleDismissError}
- presetVoices={presetVoices}
+ previewCount={previewCount}
+ previewTitles={previewTitles}
+ onPreviewTitlesChange={setPreviewTitles}
+ voiceModePerVideo={voiceModePerVideo}
+ onVoiceModePerVideoChange={setVoiceModePerVideo}
+ voiceLibraryIds={voiceLibraryIds}
+ onVoiceLibraryIdsChange={setVoiceLibraryIds}
+ previewCovers={previewCovers}
+ onPreviewCoversChange={setPreviewCovers}
+ selectedVariantIds={selectedVariantIds}
/>
{
generating={generating}
generated={generated}
generateError={generateError}
+ selectedCount={isBatch ? selectedVariantIds.length : 1}
/>
- {/* ════ 右侧:步骤4实时预览,步骤5/6最终视频 ════ */}
-
- {currentStep === 4 && !!currentTemplate && (
-
0}
- serverClips={serverClips}
- voiceAudioUrl={previewVoiceAudioUrl || undefined}
- titleSettings={{
- title: titleSettings.title,
- 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,
- }}
- onTitlePositionChange={styleUpdaters.updateTitlePosition}
- />
- )}
- {currentStep >= 5 && generated && finalVideo && (
+ {/* ════ 步骤5(封面):成片播放器(单视频) ════ */}
+ {currentStep === 5 && !isBatch && generated && finalVideo && (
+
- )}
-
+
+ )}
+ {/* 数量选择弹窗 */}
+
setCountModalOpen(false)}
+ />
+
{/* 音色克隆弹窗 */}
= ({
+const GenerateStepActions: React.FC = ({
currentStep,
onPrev,
onNext,
@@ -27,9 +29,10 @@ export const GenerateStepActions: React.FC = ({
generating,
generated,
generateError,
+ selectedCount = 1,
}) => {
const renderPrimaryButton = () => {
- /* 步骤 1~3:上一步 / 下一步(必填校验由 useStepNavigation.goNext 统一处理) */
+ /* 步骤 1~3:上一步 / 下一步 */
if (currentStep < 4) {
return (