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 ( ) } return ( ) } - /* 步骤 5:渲染中禁用,完成后下一步进入封面 */ - if (currentStep === 5) { - return ( - - ) - } - - /* 步骤 6(最后一步):无主按钮 */ + /* 步骤 5(封面,最后一步):无主按钮 */ return null } diff --git a/apps/web/src/pages/generate/components/GenerateStepContent.tsx b/apps/web/src/pages/generate/components/GenerateStepContent.tsx index 1b160b5ec..47a1a7ba8 100644 --- a/apps/web/src/pages/generate/components/GenerateStepContent.tsx +++ b/apps/web/src/pages/generate/components/GenerateStepContent.tsx @@ -1,19 +1,16 @@ /** * GeneratePage 步骤内容渲染 - * 步骤顺序(6步):模板(1) → 素材(2) → 配音(3) → 标题(4) → 确认生成(5) → 封面(6) + * 步骤顺序(5步,Issue #1677):模板(1) → 素材(2) → 配音(3) → 标题+预览+确认生成(4) → 封面(5) */ import React from "react" import type { EditingTemplate } from "@/api/editing-planner" import type { EditPlanClip } from "@/api/template-editor" -import type { PresetVoiceItem } from "@/api/voices" -import type { VoiceClone } from "@/api/voice-clone" import type { CoverConfig } from "../types/cover" import type { TitleSettings } from "../types" import Step1TemplateSelect from "../components/Step1TemplateSelect" import Step2MaterialSelect from "../components/Step2MaterialSelect" -import Step3VoiceSelect from "../components/Step5VoiceSelect" +import Step3VoiceWithMode from "./Step3VoiceWithMode" import Step4TitleSettings from "../components/Step4TitleSettings" -import Step5ConfirmGenerate from "../components/Step7ConfirmGenerate" import Step6CoverSettings from "../components/Step6CoverSettings" import type { GeneratedVideo } from "@/api/template-editor" @@ -50,15 +47,6 @@ export interface GenerateStepContentProps { selectedVoice: string onSelectedVoiceChange: (id: string) => void onServerClipsChange: (clips: EditPlanClip[]) => void - voiceMode: "preset" | "custom" | "clone" - onVoiceModeChange: (mode: "preset" | "custom" | "clone") => void - selectedClonedVoice: string - onSelectedClonedVoiceChange: (id: string) => void - clonedVoices: VoiceClone[] - addClone: (voice: VoiceClone) => void - hasProcessing: boolean - cloneModalOpen: boolean - onCloneModalOpenChange: (open: boolean) => void /* 生成 */ generating: boolean generated: boolean @@ -67,12 +55,22 @@ export interface GenerateStepContentProps { generatedVideos: GeneratedVideo[] onRetry: () => void onDismissError: () => void - /* 其他 */ - presetVoices: PresetVoiceItem[] /** BGM 开关 */ bgm: boolean /** BGM 配置(来自模板) */ bgmConfig?: { enabled: boolean; music_id?: string } + /* ── 批量生成(#1677)── */ + previewCount: number + previewTitles: string[] + onPreviewTitlesChange: (titles: string[]) => void + voiceModePerVideo: boolean + onVoiceModePerVideoChange: (v: boolean) => void + voiceLibraryIds: string[] + onVoiceLibraryIdsChange: (ids: string[]) => void + previewCovers: string[] + onPreviewCoversChange: (urls: string[]) => void + /** 批量模式勾选的变体索引(封面卡片按勾选顺序展示) */ + selectedVariantIds?: number[] } export const GenerateStepContent: React.FC = (props) => { @@ -104,17 +102,17 @@ export const GenerateStepContent: React.FC = (props) = selectedVoice, onSelectedVoiceChange, onServerClipsChange, - voiceMode, - selectedClonedVoice, - clonedVoices, - generating, - generated, - generateError, - progress, generatedVideos, - onRetry, - onDismissError, - presetVoices, + previewCount, + previewTitles, + onPreviewTitlesChange, + voiceModePerVideo, + onVoiceModePerVideoChange, + voiceLibraryIds, + onVoiceLibraryIdsChange, + previewCovers, + onPreviewCoversChange, + selectedVariantIds, } = props /* 当前模板的 segments,传给 Step2 构建 clips */ @@ -146,9 +144,14 @@ export const GenerateStepContent: React.FC = (props) = ) case 3: return ( - ) case 4: @@ -167,33 +170,12 @@ export const GenerateStepContent: React.FC = (props) = onApplyPreset={onApplyPreset} activePreset={activePreset} titlePresets={titlePresets} + previewCount={previewCount} + previewTitles={previewTitles} + onPreviewTitlesChange={onPreviewTitlesChange} /> ) case 5: - return ( - - ) - case 6: return ( = (props) = selectedTemplate={selectedTemplate} titleSettings={titleSettings} generatedVideos={generatedVideos} + previewCount={previewCount} + previewTitles={previewTitles} + previewCovers={previewCovers} + onPreviewCoversChange={onPreviewCoversChange} + selectedVariantIndexes={selectedVariantIds} /> ) default: diff --git a/apps/web/src/pages/generate/components/PreviewCountModal.tsx b/apps/web/src/pages/generate/components/PreviewCountModal.tsx new file mode 100644 index 000000000..f8a334bf7 --- /dev/null +++ b/apps/web/src/pages/generate/components/PreviewCountModal.tsx @@ -0,0 +1,127 @@ +/** + * 生成数量选择弹窗(Issue #1677) + * Step1 选完模板点「下一步」时弹出:要生成几个视频?(1~10) + * 默认 1,回车 = 1(零额外操作) + */ +import React, { useState, useEffect, useRef } from "react" +import { MAX_PREVIEW_COUNT } from "../constants" + +interface PreviewCountModalProps { + open: boolean + /** 默认值(上次选择,默认1) */ + defaultCount?: number + onConfirm: (count: number) => void + onCancel: () => void +} + +const PreviewCountModal: React.FC = ({ + open, + defaultCount = 1, + onConfirm, + onCancel, +}) => { + const [count, setCount] = useState(defaultCount) + const inputRef = useRef(null) + + useEffect(() => { + if (open) { + setCount(defaultCount) + // 弹窗打开后聚焦并选中,方便直接回车=默认1 + setTimeout(() => inputRef.current?.focus(), 50) + } + }, [open, defaultCount]) + + const clamp = (n: number) => Math.max(1, Math.min(MAX_PREVIEW_COUNT, n || 1)) + + const handleConfirm = () => { + onConfirm(clamp(count)) + } + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault() + handleConfirm() + } + if (e.key === "Escape") { + onCancel() + } + } + + if (!open) return null + + return ( +
+
e.stopPropagation()}> +

要生成几个视频?

+

+ 素材共用,AI 随机剪辑出不同版本,每个视频可独立设置标题、配音和封面 +

+ +
+ + setCount(clamp(parseInt(e.target.value, 10) || 1))} + onKeyDown={handleKeyDown} + className="xx-count-input" + /> + +
+ +
+ {[1, 3, 5, 10].map((n) => ( + + ))} +
+ +
+ + +
+

+ 直接按回车 = 生成 1 个 +

+
+
+ ) +} + +export default PreviewCountModal diff --git a/apps/web/src/pages/generate/components/ServerPreviewGrid.tsx b/apps/web/src/pages/generate/components/ServerPreviewGrid.tsx new file mode 100644 index 000000000..7e7b0d601 --- /dev/null +++ b/apps/web/src/pages/generate/components/ServerPreviewGrid.tsx @@ -0,0 +1,127 @@ +/** + * 批量预览网格(Issue #1677) + * N 个服务器渲染的预览视频,网格排列、各自独立播放、CSS 标题浮层实时叠加、勾选框批量选择 + */ +import React from "react" +import { LoadingOutlined, CheckCircleFilled, CloseCircleOutlined } from "@ant-design/icons" +import type { VariantPreview } from "../hooks/useBatchPreview" + +interface ServerPreviewGridProps { + variants: VariantPreview[] + /** 每个变体的标题文字(实时叠加浮层) */ + titles: string[] + /** 标题样式(全局共用) */ + titleStyle: { + position: string + color: string + size: number + } + /** 勾选的变体索引 */ + selectedIds: number[] + onToggleSelect: (index: number) => 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/Step3VoiceWithMode.tsx b/apps/web/src/pages/generate/components/Step3VoiceWithMode.tsx new file mode 100644 index 000000000..ce832ae1d --- /dev/null +++ b/apps/web/src/pages/generate/components/Step3VoiceWithMode.tsx @@ -0,0 +1,102 @@ +/** + * Step3 配音选择(Issue #1677 批量生成) + * - 单视频 / 共用模式:与原配音选择完全一致 + * - 独立模式(开关开启):N 个配音选择器,每个视频独立选择 + */ +import React from "react" +import Step3VoiceSelect from "./Step5VoiceSelect" + +interface Step3VoiceWithModeProps { + previewCount: number + /** 共用配音ID */ + selectedVoice: string + onSelectedVoiceChange: (id: string) => void + /** 是否独立配音 */ + voiceModePerVideo: boolean + onVoiceModePerVideoChange: (v: boolean) => void + /** 各变体独立配音ID */ + voiceLibraryIds: string[] + onVoiceLibraryIdsChange: (ids: string[]) => void +} + +const Step3VoiceWithMode: React.FC = ({ + previewCount, + selectedVoice, + onSelectedVoiceChange, + voiceModePerVideo, + onVoiceModePerVideoChange, + voiceLibraryIds, + onVoiceLibraryIdsChange, +}) => { + const isBatch = previewCount > 1 + + if (!isBatch) { + return ( + + ) + } + + return ( +
+ {/* 共用/独立切换 */} +
+
+
+ 🎙️ 配音方式:{voiceModePerVideo ? "每个视频独立配音" : "所有视频共用配音"} +
+
+ {voiceModePerVideo + ? `为 ${previewCount} 个视频分别选择不同配音` + : "所有视频使用同一个配音(默认)"} +
+
+
onVoiceModePerVideoChange(!voiceModePerVideo)} + role="switch" + aria-checked={voiceModePerVideo} + tabIndex={0} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault() + onVoiceModePerVideoChange(!voiceModePerVideo) + } + }} + > +
+
+
+ + {!voiceModePerVideo ? ( + + ) : ( +
+ {Array.from({ length: previewCount }, (_, i) => ( + { + const next = [...voiceLibraryIds] + next[i] = id + onVoiceLibraryIdsChange(next) + }} + /> + ))} +
+ )} +
+ ) +} + +export default Step3VoiceWithMode diff --git a/apps/web/src/pages/generate/components/Step4TitleSettings.tsx b/apps/web/src/pages/generate/components/Step4TitleSettings.tsx index 8a457b52b..7c92a93ba 100644 --- a/apps/web/src/pages/generate/components/Step4TitleSettings.tsx +++ b/apps/web/src/pages/generate/components/Step4TitleSettings.tsx @@ -1,12 +1,13 @@ /** - * Step 4 选择标题(合并原 Step4 标题输入 + Step5 标题样式面板) + * Step 4 选择标题(Issue #1677 批量生成改造) * - * 左侧:标题文字输入 + AI生成标题 + 样式设置(位置/字号/字体/颜色/样式/预设) - * 右侧:FrontendPreviewPlayer 实时预览(由 GeneratePage 统一渲染) + * 布局(由 GeneratePage 编排):左侧大区域预览,右侧边栏标题设置。 + * 本组件渲染在右侧边栏: + * - 标题文字:1 个视频 1 个输入框;N 个视频 N 个输入框各自独立 + * - 标题样式(字体/颜色/位置/大小/粗斜描边/预设):全局统一 */ import React from "react" -import { AutoComplete } from "antd" -import { PlayCircleOutlined } from "@ant-design/icons" +import { AutoComplete, Input } from "antd" import type { TitleSettings } from "../types" import { POSITION_OPTIONS, FONT_OPTIONS } from "../constants" import { useStep4Title } from "../hooks/useStep4Title" @@ -29,6 +30,12 @@ interface Step4TitleSettingsProps { onApplyPreset: (presetKey: string) => void activePreset: string | null titlePresets: { key: string; label: string; previewStyle: React.CSSProperties }[] + /* ── 批量生成(#1677)── */ + /** 生成数量 */ + previewCount?: number + /** 每个变体的标题文字(长度=previewCount) */ + previewTitles?: string[] + onPreviewTitlesChange?: (titles: string[]) => void } const Step4TitleSettings: React.FC = (props) => { @@ -44,122 +51,138 @@ const Step4TitleSettings: React.FC = (props) => { onApplyPreset, activePreset, titlePresets, + previewCount = 1, + previewTitles, + onPreviewTitlesChange, } = props + const isBatch = previewCount > 1 + + /** 更新单个变体标题;变体0同步写回 titleSettings.title(全局样式面板/草稿保存依赖) */ + const updateVariantTitle = (index: number, val: string) => { + if (!previewTitles || !onPreviewTitlesChange) return + const next = [...previewTitles] + next[index] = val + onPreviewTitlesChange(next) + if (index === 0) { + t.updateTitle(val) + } + } + return ( -
+

📝 选择标题

- {/* AI 自动选择模式 */} - {t.titleSettings.aiAutoSelect && ( + {!isBatch ? ( + /* ── 单视频:原有 AI 标题 + 输入框(保持不变) ── */ <> -
- AI 自动选择标题 -
-
-
-
- - {/* 显示当前 AI 选中的标题(只读)+ 换一个按钮 */} -
- -
- {t.titleSettings.title || "AI 将自动为你选择标题"} - -
-
- - )} - - {/* 手动选择模式 */} - {!t.titleSettings.aiAutoSelect && ( - <> - - -
- AI 自动选择标题 -
-
-
-
- -
- - t.updateTitle(val || "")} - options={t.userTitles.map((ut) => ({ - label: ut.content, - value: ut.content, - }))} - filterOption={(inputValue, option) => { - const title = (option?.label || option?.value || "") as string - return title.toLowerCase().includes((inputValue || "").toLowerCase()) - }} - notFoundContent={ - t.userTitles.length === 0 ? ( - - 标题库为空,请前往「标题管理」添加 + {t.titleSettings.aiAutoSelect ? ( + <> +
+ AI 自动选择标题 +
+
+
+
+
+ +
+ + {(previewTitles?.[0] ?? t.titleSettings.title) || "AI 将自动为你选择标题"} - ) : null - } - /> -
+ +
+
+ + ) : ( + <> + + +
+ AI 自动选择标题 +
+
+
+
+
+ + { + t.updateTitle(val || "") + onPreviewTitlesChange?.([val || ""]) + }} + options={t.userTitles.map((ut) => ({ label: ut.content, value: ut.content }))} + filterOption={(inputValue, option) => { + const title = (option?.label || option?.value || "") as string + return title.toLowerCase().includes((inputValue || "").toLowerCase()) + }} + /> +
+ + )} + ) : ( + /* ── 批量:N 个独立标题输入框(CSS 浮层实时叠加到对应预览) ── */ +
+
+ 为每个视频输入独立标题,修改会实时叠加到左侧对应视频上。标题样式(字体/颜色/位置)全局统一。 +
+ {Array.from({ length: previewCount }, (_, i) => ( +
+ + updateVariantTitle(i, e.target.value)} + /> +
+ ))} +
)} - {/* 标题样式面板(原 Step5) */} -
- - - 右侧为实时预览,调整样式即时生效 - -
- + {/* 标题样式面板(全局共用) */} void + /** 卡片标题(独立配音模式下显示"视频 N 的配音"),默认"选择配音" */ + heading?: string + /** 描述文案 */ + description?: string + /** 是否使用紧凑卡片样式(独立配音模式下 N 个并排) */ + compact?: boolean } /** 获取素材实际时长(优先顶层 duration,fallback 到 metadata.duration) */ @@ -43,6 +49,9 @@ const formatFileSize = (bytes?: number): string => { const Step5VoiceSelect: React.FC = ({ selectedVoice, onSelectedVoiceChange, + heading = "🎙️ 选择配音", + description = "从配音库中选择已上传的素材,点击卡片可预览播放", + compact = false, }) => { const navigate = useNavigate() const [playingId, setPlayingId] = useState(null) @@ -148,14 +157,14 @@ const Step5VoiceSelect: React.FC = ({ return (
-

🎙️ 选择配音

-

- 从配音库中选择已上传的素材,点击卡片可预览播放 -

+

{heading}

+

{description}

diff --git a/apps/web/src/pages/generate/components/Step6CoverSettings.tsx b/apps/web/src/pages/generate/components/Step6CoverSettings.tsx index d394a5f9a..41eaba9c0 100755 --- a/apps/web/src/pages/generate/components/Step6CoverSettings.tsx +++ b/apps/web/src/pages/generate/components/Step6CoverSettings.tsx @@ -1,9 +1,16 @@ -import React from "react" +/** + * Step 5 选择封面(Issue #1677 批量生成改造) + * - 单视频:保留原封面流程(自动生成/封面设置模板/封面预览) + * - N 个视频:N 张封面卡片,每张带对应视频标题,可逐个自动生成或上传 + */ +import React, { useRef } from "react" import { Modal, Spin } from "antd" +import { LoadingOutlined } from "@ant-design/icons" import type { CoverConfig } from "../types/cover" import type { GeneratedVideo } from "@/api/template-editor" import type { TitleSettings } from "../types" import { useStep6Cover } from "../hooks/useStep6Cover" +import { useBatchCovers } from "../hooks/useBatchCovers" import Button from "@/components/ui/Button" import CoverSettingsModal from "./cover-settings/CoverSettingsModal" import CoverEditorModal from "./cover-settings/CoverEditorModal" @@ -17,6 +24,15 @@ interface Step6CoverSettingsProps { titleSettings?: TitleSettings /** 确认生成步骤产出的最终视频列表 */ generatedVideos: GeneratedVideo[] + /* ── 批量生成(#1677)── */ + previewCount?: number + /** 每个变体的标题文字 */ + previewTitles?: string[] + /** 每个变体的封面URL(按变体索引) */ + previewCovers?: string[] + onPreviewCoversChange?: (urls: string[]) => void + /** 勾选的变体索引(批量封面按此顺序展示,与最终成片顺序一致) */ + selectedVariantIndexes?: number[] } const Step6CoverSettings: React.FC = (props) => { @@ -46,13 +62,173 @@ const Step6CoverSettings: React.FC = (props) => { generatedVideos: props.generatedVideos, }) - const handleAutoGenerate = () => { - generateAutoCover() - } + const previewCount = props.previewCount || 1 + const isBatch = previewCount > 1 + const previewTitles = props.previewTitles || [] + const previewCovers = props.previewCovers || [] + /** 卡片展示的变体索引顺序:批量=勾选顺序(与成片顺序一致),单视频=[0] */ + const cardIndexes = + isBatch && props.selectedVariantIndexes?.length + ? props.selectedVariantIndexes + : Array.from({ length: previewCount }, (_, i) => i) + const uploadInputRef = useRef(null) + const uploadTargetRef = useRef(0) + + const completedVideos = props.generatedVideos.filter((v) => v.status === "completed") + const batchTitles = cardIndexes.map((vi) => previewTitles[vi] || "") + const batchCoversList = cardIndexes.map((vi) => previewCovers[vi] || "") + const batchCovers = useBatchCovers({ + selectedTemplate: props.selectedTemplate || "", + generatedVideos: props.generatedVideos, + titles: batchTitles, + titleStyle: { + font: props.titleSettings?.font || "思源黑体", + size: props.titleSettings?.size || 28, + color: props.titleSettings?.color || "#ffffff", + position: props.titleSettings?.position || "top", + bold: props.titleSettings?.bold ?? true, + stroke: props.titleSettings?.stroke ?? true, + shadow: props.titleSettings?.shadow ?? false, + }, + covers: batchCoversList, + onCoversChange: (urls) => { + // 按卡片顺序写回对应变体索引 + const next = [...(props.previewCovers || [])] + cardIndexes.forEach((vi, cardPos) => { + next[vi] = urls[cardPos] || "" + }) + props.onPreviewCoversChange?.(next) + }, + }) - // 预览图:优先 thumbnail_url,其次 upload_url const previewUrl = coverSettings.thumbnail_url || coverSettings.upload_url + const handleUploadClick = (variantIndex: number) => { + uploadTargetRef.current = variantIndex + uploadInputRef.current?.click() + } + + const handleFileChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0] + e.target.value = "" + if (file) { + const variantIndex = uploadTargetRef.current + const cardPos = cardIndexes.indexOf(variantIndex) + if (cardPos >= 0) void batchCovers.uploadOne(cardPos, file) + } + } + + /* ── 批量封面 ── */ + if (isBatch) { + return ( +
+

🖼️ 选择封面

+ +
+ 🎬 共 {completedVideos.length} 个成片,封面将从对应成片中智能选帧并叠加该视频的标题 +
+ +
+ +
+ +
+ {cardIndexes.map((variantIndex, cardPos) => { + const url = batchCoversList[cardPos] + const isLoading = batchCovers.loadingIndex === cardPos + const isUploading = batchCovers.uploadingIndex === cardPos + const title = batchTitles[cardPos] + return ( +
+
视频 {variantIndex + 1}
+
+ {isLoading || isUploading ? ( +
+ } /> + {isLoading ? "AI 选帧中…" : "上传中…"} +
+ ) : url ? ( + {`视频${variantIndex + ) : ( +
+ 🖼️ + 未设置封面 +
+ )} +
9:16
+
+ {title && ( +
+ 标题:{title} +
+ )} +
+ + +
+
+ ) + })} +
+ + +
+ ) + } + + /* ── 单视频:原有流程保持不变 ── */ return (

🖼️ 选择封面

@@ -75,7 +251,7 @@ const Step6CoverSettings: React.FC = (props) => { )}
-