Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 20fe447efb | |||
| ac28530528 |
@@ -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`)
|
||||
|
||||
@@ -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 = () => {
|
||||
@@ -132,6 +132,8 @@ const GeneratePage: React.FC = () => {
|
||||
|
||||
const [previewVoiceAudioUrl, setPreviewVoiceAudioUrl] = useState<string | null>(null)
|
||||
const ttsAbortRef = useRef<AbortController | null>(null)
|
||||
// TTS 试听文案:批量跟随变体0标题(仅取首项,避免编辑其他变体标题触发多余 TTS 请求)
|
||||
const variant0Title = isBatch ? previewTitles[0] || "" : ""
|
||||
|
||||
useEffect(() => {
|
||||
const voiceAsset = voiceMaterials.find((m) => m.id === selectedVoice)
|
||||
@@ -140,8 +142,10 @@ const GeneratePage: React.FC = () => {
|
||||
return
|
||||
}
|
||||
|
||||
// 批量模式下 TTS 文案跟随变体0标题;单视频跟随主标题
|
||||
const ttsTitle = isBatch ? variant0Title || "" : titleSettings.title
|
||||
const voiceId = selectedClonedVoice || selectedVoice
|
||||
if (!voiceId || !titleSettings.title) {
|
||||
if (!voiceId || !ttsTitle) {
|
||||
setPreviewVoiceAudioUrl(null)
|
||||
return
|
||||
}
|
||||
@@ -151,7 +155,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 +172,14 @@ const GeneratePage: React.FC = () => {
|
||||
cancelled = true
|
||||
controller.abort()
|
||||
}
|
||||
}, [selectedVoice, selectedClonedVoice, titleSettings.title, voiceMaterials])
|
||||
}, [
|
||||
selectedVoice,
|
||||
selectedClonedVoice,
|
||||
titleSettings.title,
|
||||
variant0Title,
|
||||
isBatch,
|
||||
voiceMaterials,
|
||||
])
|
||||
|
||||
/* ── 克隆声音 ── */
|
||||
const { addClone } = useCloneProgress()
|
||||
@@ -207,76 +218,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 +239,10 @@ const GeneratePage: React.FC = () => {
|
||||
generated,
|
||||
generateError,
|
||||
generatedVideos,
|
||||
batchTasks,
|
||||
generate: handleGenerate,
|
||||
retry: handleRetryGenerate,
|
||||
retryBatchTask: handleRetryBatchTask,
|
||||
dismissError: handleDismissError,
|
||||
download: handleDownload,
|
||||
share: handleShare,
|
||||
@@ -365,31 +310,43 @@ 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("请至少勾选一个视频")
|
||||
return
|
||||
// 标题校验:批量只校验已勾选的变体;单视频校验主标题
|
||||
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.title?.trim()) {
|
||||
// 与 buildPayload.validateGenerateInputs 一致:AI 自动选标题模式(aiAutoSelect)
|
||||
// 允许空标题由后端生成;手动模式必须填写,避免提交空标题
|
||||
if (!titleSettings.aiAutoSelect) {
|
||||
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 +360,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 +385,12 @@ const GeneratePage: React.FC = () => {
|
||||
|
||||
<GenerateStepsBar currentStep={currentStep} onStepClick={setCurrentStep} />
|
||||
|
||||
<div
|
||||
className={`xx-generate-layout${currentStep < 4 ? " full-width" : ""}${
|
||||
currentStep === 4 ? " step4-layout" : ""
|
||||
}`}
|
||||
>
|
||||
{/* ════ 步骤4:左侧预览大区域 ════ */}
|
||||
<div className={layoutClassName}>
|
||||
{/* ════ 步骤4:左侧预览大区域(纯前端 Canvas 实时预览) ════ */}
|
||||
{currentStep === 4 && !!currentTemplate && (
|
||||
<div className="xx-generate-preview-col">
|
||||
{!isBatch ? (
|
||||
/* 单视频:前端 Canvas 实时预览(与旧版一致) */
|
||||
/* 单视频:前端 Canvas 实时预览(与旧版一致,零回归) */
|
||||
<FrontendPreviewPlayer
|
||||
assets={previewAssets}
|
||||
template={currentTemplate}
|
||||
@@ -466,109 +414,32 @@ const GeneratePage: React.FC = () => {
|
||||
onTitlePositionChange={styleUpdaters.updateTitlePosition}
|
||||
/>
|
||||
) : (
|
||||
/* 批量:服务器预览网格 */
|
||||
/* 批量:N 个前端 Canvas 预览网格(不调任何后端渲染接口,秒开) */
|
||||
<div className="xx-form-section">
|
||||
<div className="xx-preview-header">
|
||||
<h3>🎬 {previewCount} 个视频预览</h3>
|
||||
{batchPreviewStatus === "loading" && (
|
||||
<span style={{ fontSize: 13, color: "var(--text-secondary, #666)" }}>
|
||||
{batchPreviewProgress}%
|
||||
</span>
|
||||
)}
|
||||
{(batchPreviewStatus === "failed" || batchPreviewStatus === "partial_failed") && (
|
||||
<button
|
||||
type="button"
|
||||
className="xx-btn xx-btn-ghost xx-btn-sm"
|
||||
onClick={retryBatchPreview}
|
||||
>
|
||||
🔄 重新生成预览
|
||||
</button>
|
||||
)}
|
||||
<span style={{ fontSize: 13, color: "var(--text-secondary, #666)" }}>
|
||||
实时预览,勾选要生成的视频
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{batchGeneratingText && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color:
|
||||
batchPreviewStatus === "failed"
|
||||
? "var(--error-color, #ef4444)"
|
||||
: "var(--text-secondary, #666)",
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
{batchGeneratingText}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ServerPreviewGrid
|
||||
variants={variants}
|
||||
<CanvasPreviewGrid
|
||||
count={previewCount}
|
||||
assets={previewAssets}
|
||||
template={currentTemplate}
|
||||
videoRatio={videoRatio}
|
||||
titles={previewTitles}
|
||||
titleStyle={{
|
||||
position: titleSettings.position,
|
||||
color: titleSettings.color,
|
||||
size: titleSettings.size,
|
||||
}}
|
||||
titleSettings={titleSettings}
|
||||
voiceAudioUrl={previewVoiceAudioUrl || undefined}
|
||||
selectedIds={selectedVariantIds}
|
||||
onToggleSelect={toggleVariantSelect}
|
||||
selectable={!generating}
|
||||
/>
|
||||
|
||||
{/* 生成中进度(批量) */}
|
||||
{generating && (
|
||||
<div className="xx-gen-progress-card" style={{ marginTop: 16 }}>
|
||||
<div className="xx-gen-progress-header">
|
||||
<div className="xx-gen-progress-info">
|
||||
<div className="xx-gen-progress-phase">
|
||||
⏳ 正在渲染 {selectedVariantIds.length} 个最终视频… {Math.round(progress)}
|
||||
%
|
||||
</div>
|
||||
<div className="xx-gen-progress-sub">
|
||||
生成过程中可以切换到其他页面,完成后可在任务历史查看
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="xx-gen-progress-bar">
|
||||
<div
|
||||
className="xx-gen-progress-bar-fill"
|
||||
style={{ width: `${Math.min(Math.round(progress), 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{generateError && !generating && (
|
||||
<div className="xx-gen-error-card" style={{ marginTop: 16 }}>
|
||||
<div className="xx-gen-error-info">
|
||||
<div className="xx-gen-error-title">生成失败</div>
|
||||
<div className="xx-gen-error-msg">{generateError}</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="xx-btn xx-btn-primary xx-btn-sm"
|
||||
onClick={handleRetryGenerate}
|
||||
>
|
||||
🔄 重试
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{generated && !generating && (
|
||||
<div className="xx-gen-success-card" style={{ marginTop: 16 }}>
|
||||
<div className="xx-gen-success-info">
|
||||
<div className="xx-gen-success-title">✅ 视频生成完成!</div>
|
||||
<div className="xx-gen-success-sub">
|
||||
共生成 {generatedVideos.length} 条视频,点击「下一步」为每个视频选择封面
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ════ 右侧:步骤1~3 表单 / 步骤4 标题边栏 / 步骤5 封面 ════ */}
|
||||
{/* ════ 右侧:步骤1~3 表单 / 步骤4 标题边栏 / 步骤5 确认生成进度 / 步骤6 封面 ════ */}
|
||||
<div className="xx-generate-form">
|
||||
<GenerateStepContent
|
||||
currentStep={currentStep}
|
||||
@@ -606,7 +477,9 @@ const GeneratePage: React.FC = () => {
|
||||
progress={progress}
|
||||
generatedVideos={generatedVideos}
|
||||
onRetry={handleRetryGenerate}
|
||||
onRetryBatchTask={handleRetryBatchTask}
|
||||
onDismissError={handleDismissError}
|
||||
batchTasks={batchTasks}
|
||||
previewCount={previewCount}
|
||||
previewTitles={previewTitles}
|
||||
onPreviewTitlesChange={setPreviewTitles}
|
||||
@@ -631,16 +504,16 @@ const GeneratePage: React.FC = () => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ════ 步骤5(封面):成片播放器(单视频) ════ */}
|
||||
{currentStep === 5 && !isBatch && generated && finalVideo && (
|
||||
{/* ════ 步骤5/6(单视频):右侧成片播放器 ════ */}
|
||||
{currentStep >= 5 && !isBatch && generated && finalVideo && (
|
||||
<div className="xx-generate-right-col">
|
||||
<div className="xx-inline-video-player">
|
||||
<video
|
||||
src={finalVideo.download_url || finalVideo.file_url}
|
||||
controls
|
||||
autoPlay
|
||||
autoPlay={currentStep === 5}
|
||||
style={{ width: "100%", maxHeight: "70vh", objectFit: "contain", borderRadius: 12 }}
|
||||
poster={finalVideo.thumbnail_url}
|
||||
poster={finalVideo.thumbnail_url || undefined}
|
||||
/>
|
||||
<div style={{ display: "flex", gap: 8, marginTop: 12, justifyContent: "center" }}>
|
||||
<button className="xx-btn xx-btn-ghost xx-btn-sm" onClick={handleDownload}>
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* 第5步「确认生成」— 批量渲染进度网格(Issue #1677)
|
||||
*
|
||||
* N 个正式生成任务各自独立卡片:进度条 / 成功成片播放 / 失败原因 + 单独重试。
|
||||
* 数据来自 useGenerateVideo 的 batchTasks(useGenerationPolling 实时回传)。
|
||||
*/
|
||||
import React from "react"
|
||||
import { LoadingOutlined, CheckCircleFilled, CloseCircleOutlined } from "@ant-design/icons"
|
||||
import type { BatchTaskState } from "../hooks/generate-video/useGenerationPolling"
|
||||
import type { GeneratedVideo } from "@/api/template-editor"
|
||||
|
||||
interface BatchGenerationGridProps {
|
||||
tasks: BatchTaskState[]
|
||||
/** 变体标题(按变体序号取) */
|
||||
titles: string[]
|
||||
/** 失败任务重试 */
|
||||
onRetryTask: (taskId: string) => void
|
||||
}
|
||||
|
||||
const BatchGenerationGrid: React.FC<BatchGenerationGridProps> = ({
|
||||
tasks,
|
||||
titles,
|
||||
onRetryTask,
|
||||
}) => {
|
||||
const sorted = [...tasks].sort((a, b) => (a.variantIndex || 0) - (b.variantIndex || 0))
|
||||
|
||||
return (
|
||||
<div className="xx-form-section">
|
||||
<div className="xx-preview-header">
|
||||
<h3>🎬 正在生成 {tasks.length} 个视频</h3>
|
||||
<span style={{ fontSize: 13, color: "var(--text-secondary, #666)" }}>
|
||||
完成 {tasks.filter((t) => t.status === "completed").length} / {tasks.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="xx-batch-gen-grid">
|
||||
{sorted.map((task) => {
|
||||
const title = titles[task.variantIndex] || `视频 ${task.variantIndex + 1}`
|
||||
const video = (task.videos?.[0] || null) as GeneratedVideo | null
|
||||
return (
|
||||
<div key={task.taskId} className={`xx-batch-gen-card status-${task.status}`}>
|
||||
<div className="xx-batch-gen-card-head">
|
||||
<span className="xx-batch-gen-card-title" title={title}>
|
||||
{task.status === "completed" ? (
|
||||
<CheckCircleFilled style={{ color: "#52c41a", marginRight: 6 }} />
|
||||
) : task.status === "failed" ? (
|
||||
<CloseCircleOutlined style={{ color: "#ef4444", marginRight: 6 }} />
|
||||
) : (
|
||||
<LoadingOutlined style={{ color: "#1677ff", marginRight: 6 }} />
|
||||
)}
|
||||
视频 {task.variantIndex + 1}:{title}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="xx-batch-gen-card-body">
|
||||
{task.status === "running" && (
|
||||
<>
|
||||
<div className="xx-gen-progress-bar">
|
||||
<div
|
||||
className="xx-gen-progress-bar-fill"
|
||||
style={{ width: `${Math.min(task.progress, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="xx-batch-gen-card-pct">{Math.round(task.progress)}%</div>
|
||||
</>
|
||||
)}
|
||||
{task.status === "completed" && video && (
|
||||
<video
|
||||
src={video.download_url || video.file_url}
|
||||
controls
|
||||
style={{ width: "100%", borderRadius: 8, background: "#000", maxHeight: 280 }}
|
||||
poster={video.thumbnail_url}
|
||||
/>
|
||||
)}
|
||||
{task.status === "completed" && !video && (
|
||||
<div className="xx-batch-gen-card-done">✅ 已完成(成片可在下一步选择封面)</div>
|
||||
)}
|
||||
{task.status === "failed" && (
|
||||
<div className="xx-batch-gen-card-failed">
|
||||
<div className="xx-batch-gen-card-err">{task.error || "生成失败"}</div>
|
||||
<button
|
||||
type="button"
|
||||
className="xx-btn xx-btn-primary xx-btn-sm"
|
||||
onClick={() => onRetryTask(task.taskId)}
|
||||
>
|
||||
🔄 重试此视频
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default BatchGenerationGrid
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* 批量前端 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"
|
||||
import { MAX_PREVIEW_COUNT } from "../constants"
|
||||
|
||||
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<CanvasPreviewGridProps> = ({
|
||||
count,
|
||||
assets,
|
||||
template,
|
||||
videoRatio,
|
||||
titles,
|
||||
titleSettings,
|
||||
voiceAudioUrl,
|
||||
selectedIds,
|
||||
onToggleSelect,
|
||||
selectable = true,
|
||||
}) => {
|
||||
// 硬上限保护:同时播放的媒体元素数量不超过 MAX_PREVIEW_COUNT(10),避免浏览器卡顿
|
||||
const safeCount = Math.max(1, Math.min(count, MAX_PREVIEW_COUNT))
|
||||
return (
|
||||
<div className="xx-canvas-grid">
|
||||
{Array.from({ length: safeCount }, (_, i) => {
|
||||
const checked = selectedIds.includes(i)
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={`xx-canvas-grid-card${checked ? " selected" : ""}`}
|
||||
data-variant={i}
|
||||
>
|
||||
<div className="xx-canvas-grid-card-bar">
|
||||
<label className="xx-canvas-grid-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
disabled={!selectable}
|
||||
onChange={() => onToggleSelect(i)}
|
||||
/>
|
||||
<span>视频 {i + 1}</span>
|
||||
</label>
|
||||
</div>
|
||||
<FrontendPreviewPlayer
|
||||
assets={assets}
|
||||
template={template}
|
||||
videoRatio={videoRatio}
|
||||
ready={assets.length > 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,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default CanvasPreviewGrid
|
||||
@@ -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<FrontendPreviewPlayerProps> = ({
|
||||
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<FrontendPreviewPlayerProps> = ({
|
||||
// ── 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<FrontendPreviewPlayerProps> = ({
|
||||
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<FrontendPreviewPlayerProps> = ({
|
||||
? { 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<FrontendPreviewPlayerProps> = ({
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
{titleSettings.title.split(/[//]/).map((part, i) => (
|
||||
{(effectiveTitle || "").split(/[//]/).map((part, i) => (
|
||||
<span key={i}>
|
||||
{i > 0 && <br />}
|
||||
{part}
|
||||
|
||||
@@ -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<void>
|
||||
generating: boolean
|
||||
generated: boolean
|
||||
@@ -41,26 +41,19 @@ const GenerateStepActions: React.FC<GenerateStepActionsProps> = ({
|
||||
)
|
||||
}
|
||||
|
||||
/* 步骤 4:标题+预览+确认生成 */
|
||||
/* 步骤 4:选择标题 — 确认生成 */
|
||||
if (currentStep === 4) {
|
||||
if (generating) {
|
||||
return (
|
||||
<button className="xx-btn xx-btn-primary" disabled>
|
||||
⏳ 视频生成中…
|
||||
⏳ 正在提交生成任务…
|
||||
</button>
|
||||
)
|
||||
}
|
||||
if (generateError) {
|
||||
return (
|
||||
<button className="xx-btn xx-btn-primary" onClick={onConfirmGenerate}>
|
||||
🔄 重新生成视频
|
||||
</button>
|
||||
)
|
||||
}
|
||||
if (generated) {
|
||||
return (
|
||||
<button className="xx-btn xx-btn-primary" onClick={onNext}>
|
||||
下一步:选择封面 →
|
||||
🔄 重新生成
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -71,7 +64,23 @@ const GenerateStepActions: React.FC<GenerateStepActionsProps> = ({
|
||||
)
|
||||
}
|
||||
|
||||
/* 步骤 5(封面,最后一步):无主按钮 */
|
||||
/* 步骤 5:确认生成进度页 — 全部完成后下一步进封面 */
|
||||
if (currentStep === 5) {
|
||||
if (generated) {
|
||||
return (
|
||||
<button className="xx-btn xx-btn-primary" onClick={onNext}>
|
||||
下一步:选择封面 →
|
||||
</button>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<button className="xx-btn xx-btn-primary" disabled>
|
||||
⏳ 视频渲染中…
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
/* 步骤 6(封面,最后一步):无主按钮 */
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
@@ -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<GenerateStepContentProps> = (props) =
|
||||
selectedVoice,
|
||||
onSelectedVoiceChange,
|
||||
onServerClipsChange,
|
||||
generating,
|
||||
generated,
|
||||
generateError,
|
||||
progress,
|
||||
onRetry,
|
||||
generatedVideos,
|
||||
batchTasks,
|
||||
onRetryBatchTask,
|
||||
previewCount,
|
||||
previewTitles,
|
||||
onPreviewTitlesChange,
|
||||
@@ -176,6 +189,62 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
|
||||
/>
|
||||
)
|
||||
case 5:
|
||||
/* 确认生成页:批量=逐任务进度网格;单视频=进度状态卡(成片播放器在左侧大区域) */
|
||||
if (previewCount > 1) {
|
||||
return (
|
||||
<BatchGenerationGrid
|
||||
tasks={batchTasks}
|
||||
titles={previewTitles}
|
||||
onRetryTask={onRetryBatchTask}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/* 单视频:渲染进度 / 失败重试 / 完成提示(成片播放器在右侧栏) */
|
||||
return (
|
||||
<div className="xx-form-section">
|
||||
<h3>🎬 确认生成</h3>
|
||||
{generating && (
|
||||
<div className="xx-gen-progress-card">
|
||||
<div className="xx-gen-progress-header">
|
||||
<div className="xx-gen-progress-info">
|
||||
<div className="xx-gen-progress-phase">
|
||||
⏳ 视频渲染中… {Math.round(progress)}%
|
||||
</div>
|
||||
<div className="xx-gen-progress-sub">
|
||||
生成过程中可以切换到其他页面,完成后可在任务历史查看
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="xx-gen-progress-bar">
|
||||
<div
|
||||
className="xx-gen-progress-bar-fill"
|
||||
style={{ width: `${Math.min(Math.round(progress), 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{generateError && !generating && (
|
||||
<div className="xx-gen-error-card">
|
||||
<div className="xx-gen-error-info">
|
||||
<div className="xx-gen-error-title">生成失败</div>
|
||||
<div className="xx-gen-error-msg">{generateError}</div>
|
||||
</div>
|
||||
<button type="button" className="xx-btn xx-btn-primary xx-btn-sm" onClick={onRetry}>
|
||||
🔄 重试
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{generated && !generating && (
|
||||
<div className="xx-gen-success-card">
|
||||
<div className="xx-gen-success-info">
|
||||
<div className="xx-gen-success-title">✅ 视频生成完成!</div>
|
||||
<div className="xx-gen-success-sub">右侧可预览成片,点击「下一步」选择封面</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
case 6:
|
||||
return (
|
||||
<Step6CoverSettings
|
||||
coverSettings={coverSettings}
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
/**
|
||||
* 批量预览网格(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<ServerPreviewGridProps> = ({
|
||||
variants,
|
||||
titles,
|
||||
titleStyle,
|
||||
selectedIds,
|
||||
onToggleSelect,
|
||||
selectable = true,
|
||||
}) => {
|
||||
if (variants.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="xx-variant-grid">
|
||||
{variants.map((v) => {
|
||||
const selected = selectedIds.includes(v.index)
|
||||
const titleText = titles[v.index] || ""
|
||||
return (
|
||||
<div
|
||||
key={v.index}
|
||||
className={`xx-variant-card ${selected ? "selected" : ""} ${
|
||||
v.status === "failed" ? "failed" : ""
|
||||
}`}
|
||||
onClick={() => {
|
||||
if (selectable && v.status === "ready") onToggleSelect(v.index)
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
{/* 勾选框 */}
|
||||
{selectable && v.status === "ready" && (
|
||||
<div className={`xx-variant-check ${selected ? "checked" : ""}`}>
|
||||
{selected && "✓"}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 变体序号 */}
|
||||
<div className="xx-variant-index">视频 {v.index + 1}</div>
|
||||
|
||||
{/* 视频区域 */}
|
||||
<div className="xx-variant-video-wrap">
|
||||
{v.status === "loading" && (
|
||||
<div className="xx-variant-loading">
|
||||
<LoadingOutlined style={{ fontSize: 28, color: "#3b82f6" }} />
|
||||
<div className="xx-variant-progress">
|
||||
<div
|
||||
className="xx-variant-progress-bar"
|
||||
style={{ width: `${Math.min(v.progress, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="xx-variant-progress-text">{v.progress}%</span>
|
||||
</div>
|
||||
)}
|
||||
{v.status === "failed" && (
|
||||
<div className="xx-variant-failed">
|
||||
<CloseCircleOutlined style={{ fontSize: 28, color: "#ef4444" }} />
|
||||
<span>{v.error || "预览失败"}</span>
|
||||
</div>
|
||||
)}
|
||||
{v.status === "ready" && v.videoUrl && (
|
||||
<>
|
||||
<video
|
||||
src={v.videoUrl}
|
||||
controls
|
||||
style={{ width: "100%", display: "block", background: "#000", borderRadius: 8 }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
{/* 标题浮层(CSS 实时叠加,改标题即时可见) */}
|
||||
{titleText && (
|
||||
<div
|
||||
className={`xx-variant-title-overlay pos-${titleStyle.position}`}
|
||||
style={{
|
||||
color: titleStyle.color,
|
||||
fontSize: Math.max(13, Math.round(titleStyle.size * 0.55)),
|
||||
WebkitTextStroke: "0.5px rgba(0,0,0,0.6)",
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{titleText}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 底部状态 */}
|
||||
<div className="xx-variant-footer">
|
||||
{v.status === "ready" && selected && (
|
||||
<span className="xx-variant-ready-tag">
|
||||
<CheckCircleFilled style={{ color: "#52c41a" }} /> 已选择
|
||||
</span>
|
||||
)}
|
||||
{v.status === "ready" && !selected && selectable && (
|
||||
<span className="xx-variant-skip-tag">点击卡片取消/勾选</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ServerPreviewGrid
|
||||
@@ -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<Step4TitleSettingsProps> = (props) => {
|
||||
const t = useStep4Title(props)
|
||||
const {
|
||||
@@ -57,8 +91,10 @@ const Step4TitleSettings: React.FC<Step4TitleSettingsProps> = (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<Step4TitleSettingsProps> = (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 (
|
||||
<div className="xx-form-section xx-title-sidebar">
|
||||
<h3>📝 选择标题</h3>
|
||||
|
||||
{!isBatch ? (
|
||||
/* ── 单视频:原有 AI 标题 + 输入框(保持不变) ── */
|
||||
/* ── 单视频:原有 AI 标题 + 输入框(保持不变,零回归) ── */
|
||||
<>
|
||||
{t.titleSettings.aiAutoSelect ? (
|
||||
<>
|
||||
@@ -144,7 +211,7 @@ const Step4TitleSettings: React.FC<Step4TitleSettingsProps> = (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<Step4TitleSettingsProps> = (props) => {
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
/* ── 批量:N 个独立标题输入框(CSS 浮层实时叠加到对应预览) ── */
|
||||
/* ── 批量:AI 批量生成 + N 个独立标题输入框(AutoComplete 支持标题库) ── */
|
||||
<div className="xx-batch-titles">
|
||||
<div
|
||||
style={{
|
||||
@@ -167,15 +234,52 @@ const Step4TitleSettings: React.FC<Step4TitleSettingsProps> = (props) => {
|
||||
>
|
||||
为每个视频输入独立标题,修改会实时叠加到左侧对应视频上。标题样式(字体/颜色/位置)全局统一。
|
||||
</div>
|
||||
|
||||
{/* 批量 AI 标题 */}
|
||||
<div className="xx-batch-ai-row">
|
||||
<Input
|
||||
placeholder="主题词,如:萌宠日常、旅行vlog"
|
||||
value={batchAiTopic || t.aiTitleInput}
|
||||
onChange={(e) => {
|
||||
setBatchAiTopic(e.target.value)
|
||||
t.setAiTitleInput(e.target.value)
|
||||
}}
|
||||
maxLength={30}
|
||||
size="small"
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="xx-btn xx-btn-primary xx-btn-sm"
|
||||
disabled={batchAiLoading}
|
||||
onClick={() => handleBatchAiGenerate(false)}
|
||||
>
|
||||
{batchAiLoading ? <LoadingOutlined /> : "✨"} 一键生成 {previewCount} 个标题
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="xx-btn xx-btn-ghost xx-btn-sm"
|
||||
disabled={batchAiLoading}
|
||||
onClick={() => handleBatchAiGenerate(true)}
|
||||
>
|
||||
补填空标题
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{Array.from({ length: previewCount }, (_, i) => (
|
||||
<div className="xx-form-field" key={i}>
|
||||
<label>视频 {i + 1} 标题</label>
|
||||
<Input
|
||||
<AutoComplete
|
||||
placeholder={`视频 ${i + 1} 的标题…`}
|
||||
maxLength={50}
|
||||
showCount
|
||||
value={previewTitles?.[i] || ""}
|
||||
onChange={(e) => 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())
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -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: "选择封面" },
|
||||
]
|
||||
|
||||
/* ── 批量生成限制 ── */
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<BatchTaskState>) => 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<ReturnType<typeof setTimeout>[]>([])
|
||||
const cancelledRef = useRef(false)
|
||||
/** 批量任务上下文:taskId → 变体序号 */
|
||||
const batchContextRef = useRef<Map<string, number>>(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<unknown[]> => {
|
||||
(
|
||||
taskId: string,
|
||||
runId: number,
|
||||
callbacks?: {
|
||||
onTaskProgress?: (pct: number) => void
|
||||
onTaskCompleted?: (videos: unknown[]) => void
|
||||
onTaskFailed?: (msg: string) => void
|
||||
},
|
||||
): Promise<unknown[]> => {
|
||||
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<string, number>()
|
||||
const resultMap = new Map<string, unknown[]>()
|
||||
const failureMap = new Map<string, string>()
|
||||
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 }
|
||||
}
|
||||
|
||||
@@ -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<VariantPreview[]>([])
|
||||
const [status, setStatus] = useState<"idle" | "loading" | "ready" | "partial_failed" | "failed">(
|
||||
"idle",
|
||||
)
|
||||
const requestSeqRef = useRef(0)
|
||||
const pollTimersRef = useRef<ReturnType<typeof setTimeout>[]>([])
|
||||
const timeoutTimerRef = useRef<ReturnType<typeof setTimeout> | 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<VariantPreview>) => {
|
||||
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
|
||||
@@ -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<string | null>(null)
|
||||
const [generatedVideos, setGeneratedVideos] = useState<GeneratedVideo[]>([])
|
||||
/** 批量模式:每个正式生成任务的独立状态(第5步逐卡片展示) */
|
||||
const [batchTasks, setBatchTasks] = useState<BatchTaskState[]>([])
|
||||
|
||||
const handleBatchTaskUpdate = useCallback((taskId: string, patch: Partial<BatchTaskState>) => {
|
||||
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<number, GeneratedVideo>()
|
||||
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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user