feat(generate): 批量生成支持>3视频排队提交 + 两列标题 + 独立配音 (#2096)
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 1s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 1s
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 1m59s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 2m4s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 2m5s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m16s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m57s
AI Code Review / AI Code Review (pull_request) Successful in 7m7s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 9m28s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 11m39s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 10m52s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 28m31s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 2s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 1s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 1s
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 1m59s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 2m4s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 2m5s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m16s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m57s
AI Code Review / AI Code Review (pull_request) Successful in 7m7s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 9m28s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 11m39s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 10m52s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 28m31s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 2s
- P0: 批量生成由一次性提交count=N改为串行count=1逐任务提交; 遇到 429 USER_QUEUE_FULL / 503 SYSTEM_QUEUE_FULL 自动读取 estimated_wait_seconds 后重试,不再弹错误阻止提交; 新增 useGenerationPolling.pollBatchTaskQueued 支持增量排队轮询(不触发整体 onComplete); 卡片新增 queued 状态(时钟图标+排队文案); 单个任务提交失败不阻断其余任务,失败卡片独立展示错误+重试按钮; generating 状态改为所有任务到达终态(completed/awaiting_cover/failed)且无queued/running才关闭。 - P1: Step4 批量标题改为 CSS Grid 两列布局(gridTemplateColumns: repeat(2,minmax(0,1fr))), 单字段 maxWidth 由 640 改为 100%,纵向空间节省约一半。 - P1: Step3(标题页)集成 Step3VoiceWithMode,批量模式下支持共用/独立配音切换; 独立配音时每个视频可单独选择音色,默认继承全局配音; GenerateStepContent props 已从 GeneratePage 透传,之前组件只定义未渲染,本次补齐 JSX 引用。 - BatchTaskState.status 新增 queued 枚举; - useGenerateVideo 重构 generate 函数,拆分 buildBasePayload/submitOne 逻辑, 新增 cancelledRef/queueTimersRef 做 unmount 清理。
This commit is contained in:
@@ -11,7 +11,12 @@
|
||||
* 防止长标题在窄列里溢出导致与相邻卡片进度条视觉重叠。
|
||||
*/
|
||||
import React from "react"
|
||||
import { LoadingOutlined, CheckCircleFilled, CloseCircleOutlined } from "@ant-design/icons"
|
||||
import {
|
||||
LoadingOutlined,
|
||||
CheckCircleFilled,
|
||||
CloseCircleOutlined,
|
||||
ClockCircleOutlined,
|
||||
} from "@ant-design/icons"
|
||||
import type { BatchTaskState } from "../hooks/generate-video/useGenerationPolling"
|
||||
import type { GeneratedVideo } from "@/api/template-editor"
|
||||
|
||||
@@ -61,6 +66,11 @@ const BatchGenerationGrid: React.FC<BatchGenerationGridProps> = ({
|
||||
className="xx-batch-gen-card-icon"
|
||||
style={{ color: "#ef4444" }}
|
||||
/>
|
||||
) : task.status === "queued" ? (
|
||||
<ClockCircleOutlined
|
||||
className="xx-batch-gen-card-icon"
|
||||
style={{ color: "#faad14" }}
|
||||
/>
|
||||
) : (
|
||||
<LoadingOutlined
|
||||
className="xx-batch-gen-card-icon"
|
||||
@@ -85,6 +95,21 @@ const BatchGenerationGrid: React.FC<BatchGenerationGridProps> = ({
|
||||
<div className="xx-batch-gen-card-pct">{Math.round(task.progress)}%</div>
|
||||
</>
|
||||
)}
|
||||
{task.status === "queued" && (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
color: "var(--text-secondary, #faad14)",
|
||||
fontSize: 13,
|
||||
padding: "8px 0",
|
||||
}}
|
||||
>
|
||||
<ClockCircleOutlined />
|
||||
<span>排队等待中,前面任务完成后自动开始渲染</span>
|
||||
</div>
|
||||
)}
|
||||
{(task.status === "completed" || task.status === "awaiting_cover") && video && (
|
||||
// 竖屏自适应容器(#1750):成片固定 1080×1920(9:16),
|
||||
// 视频按真实宽高比 contain 显示,黑底居中,杜绝横屏播放器左右大黑边
|
||||
|
||||
@@ -12,6 +12,7 @@ import Step2MaterialSelect from "../components/Step2MaterialSelect"
|
||||
import Step4TitleSettings from "../components/Step4TitleSettings"
|
||||
import Step6CoverSettings from "../components/Step6CoverSettings"
|
||||
import BatchGenerationGrid from "./BatchGenerationGrid"
|
||||
import Step3VoiceWithMode from "./Step3VoiceWithMode"
|
||||
import type { BatchTaskState } from "../hooks/generate-video/useGenerationPolling"
|
||||
import type { GeneratedVideo } from "@/api/template-editor"
|
||||
import type { TitleTemplate } from "@/components/title/template-types"
|
||||
@@ -151,6 +152,12 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
|
||||
selectedCoverTemplate,
|
||||
onSelectedCoverTemplateChange,
|
||||
onConfirmGenerate,
|
||||
selectedVoice,
|
||||
onSelectedVoiceChange,
|
||||
voiceModePerVideo,
|
||||
onVoiceModePerVideoChange,
|
||||
voiceLibraryIds,
|
||||
onVoiceLibraryIdsChange,
|
||||
} = props
|
||||
|
||||
switch (currentStep) {
|
||||
@@ -184,32 +191,46 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
|
||||
)
|
||||
case 3:
|
||||
return (
|
||||
<Step4TitleSettings
|
||||
titleSettings={titleSettings}
|
||||
onTitleSettingsChange={onTitleSettingsChange}
|
||||
onUpdatePosition={onUpdatePosition}
|
||||
onUpdateFont={onUpdateFont}
|
||||
onUpdateSize={onUpdateSize}
|
||||
onToggleBold={onToggleBold}
|
||||
onToggleItalic={onToggleItalic}
|
||||
onToggleStroke={onToggleStroke}
|
||||
onToggleShadow={onToggleShadow}
|
||||
onApplyPreset={onApplyPreset}
|
||||
onUpdateStyle={onUpdateStyle}
|
||||
activePreset={activePreset}
|
||||
titlePresets={titlePresets}
|
||||
enableTemplates={enableTemplates}
|
||||
selectedTemplateId={selectedTemplateId}
|
||||
onApplyTemplate={onApplyTemplate}
|
||||
previewCount={previewCount}
|
||||
previewTitles={previewTitles}
|
||||
onPreviewTitlesChange={onPreviewTitlesChange}
|
||||
onConfirmGenerate={onConfirmGenerate}
|
||||
generating={props.generating}
|
||||
selectedCount={
|
||||
props.previewCount && props.previewCount > 1 ? props.selectedVariantIds?.length || 1 : 1
|
||||
}
|
||||
/>
|
||||
<>
|
||||
<Step4TitleSettings
|
||||
titleSettings={titleSettings}
|
||||
onTitleSettingsChange={onTitleSettingsChange}
|
||||
onUpdatePosition={onUpdatePosition}
|
||||
onUpdateFont={onUpdateFont}
|
||||
onUpdateSize={onUpdateSize}
|
||||
onToggleBold={onToggleBold}
|
||||
onToggleItalic={onToggleItalic}
|
||||
onToggleStroke={onToggleStroke}
|
||||
onToggleShadow={onToggleShadow}
|
||||
onApplyPreset={onApplyPreset}
|
||||
onUpdateStyle={onUpdateStyle}
|
||||
activePreset={activePreset}
|
||||
titlePresets={titlePresets}
|
||||
enableTemplates={enableTemplates}
|
||||
selectedTemplateId={selectedTemplateId}
|
||||
onApplyTemplate={onApplyTemplate}
|
||||
previewCount={previewCount}
|
||||
previewTitles={previewTitles}
|
||||
onPreviewTitlesChange={onPreviewTitlesChange}
|
||||
onConfirmGenerate={onConfirmGenerate}
|
||||
generating={props.generating}
|
||||
selectedCount={
|
||||
props.previewCount && props.previewCount > 1
|
||||
? props.selectedVariantIds?.length || 1
|
||||
: 1
|
||||
}
|
||||
/>
|
||||
{/* 批量配音选择:共用/独立切换(#2096) */}
|
||||
<Step3VoiceWithMode
|
||||
previewCount={previewCount}
|
||||
selectedVoice={selectedVoice}
|
||||
onSelectedVoiceChange={onSelectedVoiceChange}
|
||||
voiceModePerVideo={voiceModePerVideo}
|
||||
onVoiceModePerVideoChange={onVoiceModePerVideoChange}
|
||||
voiceLibraryIds={voiceLibraryIds}
|
||||
onVoiceLibraryIdsChange={onVoiceLibraryIdsChange}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
case 4:
|
||||
return (
|
||||
|
||||
@@ -157,7 +157,7 @@ const Step4TitleSettings: React.FC<Step4TitleSettingsProps> = (props) => {
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
/* ── 批量:N 个独立标题输入框 ── */
|
||||
/* ── 批量:N 个独立标题输入框(两列布局 #2096) ── */
|
||||
<div className="xx-batch-titles">
|
||||
<div
|
||||
style={{
|
||||
@@ -170,17 +170,25 @@ const Step4TitleSettings: React.FC<Step4TitleSettingsProps> = (props) => {
|
||||
为每个视频输入独立标题。标题样式(字体/颜色/位置)全局统一。
|
||||
</div>
|
||||
|
||||
{Array.from({ length: previewCount }, (_, i) => (
|
||||
<div className="xx-form-field" key={i} style={{ maxWidth: 640 }}>
|
||||
<label>视频 {i + 1} 标题</label>
|
||||
<TitleLibraryAutoComplete
|
||||
placeholder={`输入或选择视频 ${i + 1} 的标题`}
|
||||
value={previewTitles?.[i] || ""}
|
||||
onChange={(val) => updateVariantTitle(i, val)}
|
||||
options={titleOptions}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(2, minmax(0, 1fr))",
|
||||
gap: 16,
|
||||
}}
|
||||
>
|
||||
{Array.from({ length: previewCount }, (_, i) => (
|
||||
<div className="xx-form-field" key={i} style={{ maxWidth: "100%" }}>
|
||||
<label>视频 {i + 1} 标题</label>
|
||||
<TitleLibraryAutoComplete
|
||||
placeholder={`输入或选择视频 ${i + 1} 的标题`}
|
||||
value={previewTitles?.[i] || ""}
|
||||
onChange={(val) => updateVariantTitle(i, val)}
|
||||
options={titleOptions}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ export interface BatchTaskState {
|
||||
taskId: string
|
||||
/** 变体序号(0-based,与标题/封面数组对齐) */
|
||||
variantIndex: number
|
||||
status: "running" | "completed" | "awaiting_cover" | "failed"
|
||||
status: "running" | "completed" | "awaiting_cover" | "failed" | "queued"
|
||||
progress: number
|
||||
error: string | null
|
||||
/** 完成后的成片视频 */
|
||||
@@ -374,5 +374,42 @@ export function useGenerationPolling({
|
||||
}
|
||||
}, [])
|
||||
|
||||
return { startPolling, startPollingBatch, retryTask, clearTimer }
|
||||
/**
|
||||
* 批量队列模式:逐任务追加到轮询队列(支持串行提交、429 排队重试场景)。
|
||||
* 与 startPollingBatch 不同的是:
|
||||
* - 不会 reset batchContextRef;多次调用会累积
|
||||
* - 不触发整体 onComplete / onFailed(完成判定交给外层 useEffect 按状态聚合)
|
||||
* - 仍通过 onBatchTaskUpdate 回传单任务状态
|
||||
*/
|
||||
const pollBatchTaskQueued = useCallback(
|
||||
(taskId: string, variantIndex: number) => {
|
||||
cancelledRef.current = false
|
||||
batchContextRef.current.set(taskId, variantIndex)
|
||||
onBatchTaskUpdate?.(taskId, {
|
||||
taskId,
|
||||
variantIndex,
|
||||
status: "running",
|
||||
progress: 0,
|
||||
error: null,
|
||||
videos: [],
|
||||
})
|
||||
pollSingleTask(taskId, Date.now(), {
|
||||
onTaskProgress: (pct) => {
|
||||
onBatchTaskUpdate?.(taskId, { status: "running", progress: pct })
|
||||
},
|
||||
onTaskCompleted: (videos, taskStatus) => {
|
||||
const finalStatus: "completed" | "awaiting_cover" = taskStatus ?? "completed"
|
||||
onBatchTaskUpdate?.(taskId, { status: finalStatus, progress: 100, videos })
|
||||
},
|
||||
onTaskFailed: (msg) => {
|
||||
onBatchTaskUpdate?.(taskId, { status: "failed", error: msg })
|
||||
},
|
||||
}).catch(() => {
|
||||
/* onTaskFailed 已处理 */
|
||||
})
|
||||
},
|
||||
[pollSingleTask, onBatchTaskUpdate],
|
||||
)
|
||||
|
||||
return { startPolling, startPollingBatch, pollBatchTaskQueued, retryTask, clearTimer }
|
||||
}
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
* 视频生成 Hook
|
||||
* 封装视频生成的核心逻辑、状态管理、轮询等
|
||||
*/
|
||||
import { useState, useCallback, useEffect } from "react"
|
||||
import { useState, useCallback, useEffect, useRef } from "react"
|
||||
import { message } from "antd"
|
||||
import axios from "axios"
|
||||
import { type GeneratedVideo, getEditPlanClips, createClipsFromAssets } from "@/api/template-editor"
|
||||
import { createGenerationTask } from "@/api/tasks/tasks"
|
||||
import type { CreateGenerationTaskRequest } from "@/api/tasks/types"
|
||||
import type { UseGenerateVideoProps } from "./generate-video/types"
|
||||
import { getGenerationPhase } from "./generate-video/phase"
|
||||
import { useGenerationPolling, type BatchTaskState } from "./generate-video/useGenerationPolling"
|
||||
@@ -15,6 +17,26 @@ import { extractBackendError, translateError } from "./generate-video/errorUtils
|
||||
|
||||
export type GenerationCompleteStatus = "completed" | "awaiting_cover" | null
|
||||
|
||||
/** 判断是否是用户队列已满 429(需要排队重试而非直接报错) */
|
||||
function isUserQueueFullError(err: unknown): { waitMs: number } | null {
|
||||
if (!axios.isAxiosError(err)) return null
|
||||
if (err.response?.status !== 429 && err.response?.status !== 503) return null
|
||||
const detail = (err.response?.data as { detail?: unknown })?.detail
|
||||
const code =
|
||||
typeof detail === "object" && detail !== null ? (detail as { code?: string }).code : undefined
|
||||
if (code === "USER_QUEUE_FULL" || code === "SYSTEM_QUEUE_FULL") {
|
||||
const waitSec =
|
||||
typeof detail === "object" && detail !== null
|
||||
? Number((detail as { estimated_wait_seconds?: number }).estimated_wait_seconds) || 0
|
||||
: 0
|
||||
return { waitMs: Math.max(15_000, waitSec * 1000 || 30_000) }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** sleep */
|
||||
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms))
|
||||
|
||||
export function useGenerateVideo(props: UseGenerateVideoProps) {
|
||||
const { selectedTemplate, onGenerationSuccess } = props
|
||||
|
||||
@@ -31,6 +53,22 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
|
||||
/** 批量模式:每个正式生成任务的独立状态(第5步逐卡片展示) */
|
||||
const [batchTasks, setBatchTasks] = useState<BatchTaskState[]>([])
|
||||
|
||||
/** 排队中重试的定时器,unmount / 新提交时清理 */
|
||||
const queueTimersRef = useRef<number[]>([])
|
||||
const cancelledRef = useRef(false)
|
||||
|
||||
const clearQueueTimers = useCallback(() => {
|
||||
queueTimersRef.current.forEach((id) => clearTimeout(id))
|
||||
queueTimersRef.current = []
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
cancelledRef.current = true
|
||||
clearQueueTimers()
|
||||
}
|
||||
}, [clearQueueTimers])
|
||||
|
||||
const handleBatchTaskUpdate = useCallback((taskId: string, patch: Partial<BatchTaskState>) => {
|
||||
setBatchTasks((prev) => {
|
||||
const list = prev || []
|
||||
@@ -58,7 +96,6 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
|
||||
const handleProgress = useCallback((p: number) => setProgress(p), [])
|
||||
const handleComplete = useCallback(
|
||||
(videos: unknown[], taskStatus?: "completed" | "awaiting_cover") => {
|
||||
setGenerating(false)
|
||||
setGenerated(true)
|
||||
const finalStatus: GenerationCompleteStatus = taskStatus ?? "completed"
|
||||
setCompletionStatus(finalStatus)
|
||||
@@ -81,21 +118,30 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
|
||||
[onGenerationSuccess],
|
||||
)
|
||||
const handleFailed = useCallback((errorMsg: string) => {
|
||||
setGenerating(false)
|
||||
setGenerateError(errorMsg)
|
||||
}, [])
|
||||
|
||||
/* 批量:任务状态变化时聚合已完成成片(含失败重试成功后补入),
|
||||
按变体索引排序,供步骤6封面按勾选顺序逐个取视频 */
|
||||
按变体索引排序,供步骤6封面按勾选顺序逐个取视频。
|
||||
当全部任务都已结束(completed/awaiting_cover/failed)且无排队/渲染中任务时,关闭 generating。 */
|
||||
useEffect(() => {
|
||||
if (batchTasks.length === 0) return
|
||||
const byVariant = new Map<number, GeneratedVideo>()
|
||||
let hasQueued = false
|
||||
let hasRunning = false
|
||||
let hasSuccess = false
|
||||
let allDone = true
|
||||
batchTasks.forEach((t) => {
|
||||
if (
|
||||
t.status === "completed" ||
|
||||
(t.status === "awaiting_cover" && t.videos && t.videos.length > 0)
|
||||
) {
|
||||
byVariant.set(t.variantIndex, t.videos[0] as GeneratedVideo)
|
||||
if (t.status === "queued") hasQueued = true
|
||||
else if (t.status === "running") hasRunning = true
|
||||
if (t.status === "completed" || t.status === "awaiting_cover") {
|
||||
hasSuccess = true
|
||||
if (t.videos && t.videos.length > 0) {
|
||||
byVariant.set(t.variantIndex, t.videos[0] as GeneratedVideo)
|
||||
}
|
||||
}
|
||||
if (t.status !== "completed" && t.status !== "awaiting_cover" && t.status !== "failed") {
|
||||
allDone = false
|
||||
}
|
||||
})
|
||||
const ordered = [...byVariant.entries()].sort((a, b) => a[0] - b[0]).map(([, v]) => v)
|
||||
@@ -105,17 +151,185 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
|
||||
}
|
||||
return ordered
|
||||
})
|
||||
if (allDone && !hasQueued && !hasRunning) {
|
||||
setGenerating(false)
|
||||
if (hasSuccess) {
|
||||
setGenerated(true)
|
||||
setCompletionStatus("awaiting_cover")
|
||||
}
|
||||
}
|
||||
}, [batchTasks])
|
||||
|
||||
const { startPolling, startPollingBatch, retryTask, clearTimer } = useGenerationPolling({
|
||||
const { startPolling, pollBatchTaskQueued, retryTask, clearTimer } = useGenerationPolling({
|
||||
onProgress: handleProgress,
|
||||
onComplete: handleComplete,
|
||||
onFailed: handleFailed,
|
||||
onBatchTaskUpdate: handleBatchTaskUpdate,
|
||||
})
|
||||
|
||||
/** 根据 props 构造基础 payload(批量/单任务共用的字段) */
|
||||
const buildBasePayload = useCallback((): Omit<
|
||||
CreateGenerationTaskRequest,
|
||||
"count" | "titles" | "voice_library_ids" | "cover_urls" | "variant_plan_ids"
|
||||
> => {
|
||||
const { width: outputWidth, height: outputHeight } = calculateResolution(
|
||||
props.videoRatio || "9:16",
|
||||
)
|
||||
const editMode = props.editMode ?? "random"
|
||||
const dedupEnabled = props.dedupEnabled !== false
|
||||
const assetIds =
|
||||
props.materialMode === "auto" ? props.smartSelectedIds : props.selectedMaterials
|
||||
const coverUrl = props.coverSettings?.thumbnail_url || props.coverSettings?.upload_url || ""
|
||||
// #1970:叙事模式下 ttsVoiceId 作为配音 id;随机模式用 selectedVoice
|
||||
const voiceLibraryId =
|
||||
editMode === "narrative"
|
||||
? props.ttsVoiceId || ""
|
||||
: props.voiceMode === "clone"
|
||||
? props.selectedClonedVoice || props.selectedVoice || ""
|
||||
: props.selectedVoice || ""
|
||||
|
||||
const bgmConfig = {
|
||||
enabled: props.bgm !== false,
|
||||
...(props.bgmConfig?.music_id ? { preset_id: props.bgmConfig.music_id } : {}),
|
||||
}
|
||||
|
||||
const titleConfig = props.titleSettings?.title
|
||||
? {
|
||||
text: props.titleSettings.title,
|
||||
font: props.titleSettings.font,
|
||||
font_size: props.titleSettings.size,
|
||||
font_color: props.titleSettings.color,
|
||||
position: props.titleSettings.position,
|
||||
...(props.titleSettings.position === "custom" &&
|
||||
props.titleSettings.posX != null &&
|
||||
props.titleSettings.posY != null
|
||||
? {
|
||||
pos_x: Math.round(props.titleSettings.posX),
|
||||
pos_y: Math.round(props.titleSettings.posY),
|
||||
}
|
||||
: {}),
|
||||
bold: props.titleSettings.bold,
|
||||
italic: props.titleSettings.italic,
|
||||
stroke: props.titleSettings.stroke
|
||||
? {
|
||||
enabled: true,
|
||||
width: props.titleSettings.strokeWidth ?? 4,
|
||||
color: props.titleSettings.strokeColor ?? "#000000",
|
||||
}
|
||||
: { enabled: false },
|
||||
shadow: props.titleSettings.shadow
|
||||
? {
|
||||
enabled: true,
|
||||
offset_x: props.titleSettings.shadowOffsetX ?? 2,
|
||||
offset_y: props.titleSettings.shadowOffsetY ?? 2,
|
||||
blur: props.titleSettings.shadowBlur ?? 4,
|
||||
color: props.titleSettings.shadowColor ?? "rgba(0,0,0,0.8)",
|
||||
}
|
||||
: { enabled: false },
|
||||
line_height: props.titleSettings.lineHeight ?? 1.2,
|
||||
margin_top: props.titleSettings.marginTop ?? 24,
|
||||
max_chars_per_line: props.titleSettings.maxCharsPerLine ?? 0,
|
||||
...(props.titleSettings.bgEnabled
|
||||
? {
|
||||
background: {
|
||||
enabled: true,
|
||||
color: props.titleSettings.bgColor,
|
||||
padding: props.titleSettings.bgPadding,
|
||||
radius: props.titleSettings.bgRadius,
|
||||
},
|
||||
}
|
||||
: { background: { enabled: false } }),
|
||||
line_overrides: (props.titleSettings.lineOverrides ?? []).map((lo) => ({
|
||||
line_index: lo.line_index,
|
||||
text: lo.text,
|
||||
size: lo.size,
|
||||
color: lo.color,
|
||||
bold: lo.bold,
|
||||
italic: lo.italic,
|
||||
stroke: lo.stroke,
|
||||
highlights: lo.highlights?.map((h) => ({
|
||||
word: h.word,
|
||||
color: h.color,
|
||||
bold: h.bold,
|
||||
scale: h.scale,
|
||||
})),
|
||||
})),
|
||||
...(props.titleSettings.coverTitle
|
||||
? {
|
||||
cover_title_config: {
|
||||
title: props.titleSettings.coverTitle.title,
|
||||
font: props.titleSettings.coverTitle.font,
|
||||
font_size: props.titleSettings.coverTitle.size,
|
||||
font_color: props.titleSettings.coverTitle.color,
|
||||
bold: props.titleSettings.coverTitle.bold,
|
||||
italic: props.titleSettings.coverTitle.italic,
|
||||
position: props.titleSettings.coverTitle.position,
|
||||
stroke: props.titleSettings.coverTitle.stroke
|
||||
? {
|
||||
enabled: true,
|
||||
width: props.titleSettings.coverTitle.strokeWidth ?? 4,
|
||||
color: props.titleSettings.coverTitle.strokeColor ?? "#000000",
|
||||
}
|
||||
: { enabled: false },
|
||||
shadow: props.titleSettings.coverTitle.shadow
|
||||
? {
|
||||
enabled: true,
|
||||
offset_x: props.titleSettings.coverTitle.shadowOffsetX ?? 2,
|
||||
offset_y: props.titleSettings.coverTitle.shadowOffsetY ?? 2,
|
||||
blur: props.titleSettings.coverTitle.shadowBlur ?? 4,
|
||||
color: props.titleSettings.coverTitle.shadowColor ?? "rgba(0,0,0,0.8)",
|
||||
}
|
||||
: { enabled: false },
|
||||
...(props.titleSettings.coverTitle.bgEnabled
|
||||
? {
|
||||
background: {
|
||||
enabled: true,
|
||||
color: props.titleSettings.coverTitle.bgColor,
|
||||
padding: props.titleSettings.coverTitle.bgPadding,
|
||||
radius: props.titleSettings.coverTitle.bgRadius,
|
||||
},
|
||||
}
|
||||
: { background: { enabled: false } }),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
: undefined
|
||||
|
||||
const payload: Omit<
|
||||
CreateGenerationTaskRequest,
|
||||
"count" | "titles" | "voice_library_ids" | "cover_urls" | "variant_plan_ids"
|
||||
> = {
|
||||
template_id: selectedTemplate,
|
||||
asset_ids: assetIds,
|
||||
output_width: outputWidth,
|
||||
output_height: outputHeight,
|
||||
cover_url: coverUrl,
|
||||
custom_title: props.titleSettings?.title || "",
|
||||
duration: props.duration || undefined,
|
||||
video_ratio: props.videoRatio,
|
||||
assembly_mode: editMode,
|
||||
...(editMode === "narrative" && props.selectedScript?.id
|
||||
? {
|
||||
script_id: props.selectedScript.id,
|
||||
tts_voice_id: props.ttsVoiceId || undefined,
|
||||
tts_voice_source: props.ttsVoiceSource || undefined,
|
||||
tts_style: props.ttsStyle || undefined,
|
||||
}
|
||||
: {}),
|
||||
dedup_enabled: dedupEnabled,
|
||||
voice_library_id: voiceLibraryId,
|
||||
...(props.selectedVoice && !voiceLibraryId ? { voice_ids: [props.selectedVoice] } : {}),
|
||||
bgm_config: bgmConfig as CreateGenerationTaskRequest["bgm_config"],
|
||||
...(props.sourceEditPlanId ? { source_edit_plan_id: props.sourceEditPlanId } : {}),
|
||||
...(titleConfig ? ({ title_config: titleConfig } as Record<string, unknown>) : {}),
|
||||
}
|
||||
|
||||
return payload
|
||||
}, [props, selectedTemplate])
|
||||
|
||||
/* ── 生成视频 ──
|
||||
返回 true 表示任务创建成功并已开始轮询;false 表示校验未通过或创建失败 */
|
||||
返回 true 表示任务创建成功并已开始轮询(含排队中);false 表示校验未通过或创建失败 */
|
||||
const generate = useCallback(async (): Promise<boolean> => {
|
||||
const errorMsg = validateGenerateInputs(props)
|
||||
if (errorMsg) {
|
||||
@@ -123,6 +337,8 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
|
||||
return false
|
||||
}
|
||||
|
||||
cancelledRef.current = false
|
||||
clearQueueTimers()
|
||||
setGenerating(true)
|
||||
setProgress(0)
|
||||
setGenerated(false)
|
||||
@@ -133,25 +349,16 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
|
||||
setCurrentTaskId("")
|
||||
clearTimer()
|
||||
|
||||
const basePayload = buildBasePayload()
|
||||
const assetIds = basePayload.asset_ids
|
||||
const isBatch = (props.previewCount || 1) > 1
|
||||
|
||||
try {
|
||||
const { width: outputWidth, height: outputHeight } = calculateResolution(
|
||||
props.videoRatio || "9:16",
|
||||
)
|
||||
const editMode = props.editMode ?? "random"
|
||||
const dedupEnabled = props.dedupEnabled !== false
|
||||
|
||||
const assetIds =
|
||||
props.materialMode === "auto" ? props.smartSelectedIds : props.selectedMaterials
|
||||
|
||||
// from-assets 已由 useStep2Materials 在用户选素材时(debounce 800ms)调用,
|
||||
// 后端已改为异步秒级返回,这里做一次轻量兜底:
|
||||
// 单次查 clips,已有则直接放行;没有则再调一次 from-assets。
|
||||
// from-assets 兜底:片段不存在则补一次
|
||||
if (assetIds.length > 0 && selectedTemplate) {
|
||||
try {
|
||||
const clipList = await getEditPlanClips(selectedTemplate, { limit: 500 })
|
||||
if (clipList.items.length === 0) {
|
||||
// 片段不存在(极端情况:useStep2Materials 的 debounce 还没触发)
|
||||
// 手动补一次 from-assets(后端秒级返回)
|
||||
await createClipsFromAssets(selectedTemplate, assetIds, "main")
|
||||
}
|
||||
} catch {
|
||||
@@ -159,221 +366,185 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
|
||||
}
|
||||
}
|
||||
|
||||
const isBatch = (props.previewCount || 1) > 1
|
||||
const hide = message.loading(
|
||||
isBatch ? `正在生成 ${props.previewCount} 个视频...` : "正在生成预览视频...",
|
||||
0,
|
||||
)
|
||||
|
||||
const coverUrl = props.coverSettings?.thumbnail_url || props.coverSettings?.upload_url || ""
|
||||
|
||||
// #1970:叙事模式下 ttsVoiceId 作为配音 id;随机模式用 selectedVoice
|
||||
const voiceLibraryId =
|
||||
editMode === "narrative"
|
||||
? props.ttsVoiceId || ""
|
||||
: props.voiceMode === "clone"
|
||||
? props.selectedClonedVoice || props.selectedVoice || ""
|
||||
: props.selectedVoice || ""
|
||||
|
||||
/* ── 批量变体数组(长度1=共用,长度=count=独立,空=回退单值) ── */
|
||||
const indexes =
|
||||
isBatch && props.selectedVariantIndexes?.length
|
||||
? props.selectedVariantIndexes
|
||||
: Array.from({ length: props.previewCount || 1 }, (_, i) => i)
|
||||
const batchCount = isBatch ? indexes.length : 1
|
||||
|
||||
// 标题文字数组:批量时按勾选顺序
|
||||
const titlesArr =
|
||||
isBatch && (props.variantTitles?.length || 0) >= batchCount
|
||||
? indexes.map((i) => props.variantTitles![i] || props.titleSettings?.title || "")
|
||||
: []
|
||||
// 配音数组:独立配音模式按勾选顺序;否则不传(回退共用 voice_library_id)
|
||||
const voiceArr =
|
||||
isBatch && props.voiceModePerVideo && props.variantVoiceLibraryIds?.length
|
||||
? indexes.map((i) => props.variantVoiceLibraryIds![i] || voiceLibraryId)
|
||||
: []
|
||||
// 封面数组:批量时按勾选顺序(未设置封面的变体传空串,后端回退智能封面)
|
||||
const coversArr =
|
||||
isBatch && props.variantCoverUrls?.length
|
||||
? indexes.map((i) => props.variantCoverUrls![i] || "")
|
||||
: []
|
||||
// #1744 变体 plan 数组:预览阶段后端独立选片产出的 plan id,按勾选顺序回传,
|
||||
// 后端直接关联这些 plan 渲染(不再重新选片)→ 预览所见即成片。
|
||||
// 全部为空(降级本地模拟/后端端点未上线)时不传,后端走自身独立选片。
|
||||
const variantPlansArr =
|
||||
isBatch && props.variantPlanIds?.length
|
||||
? indexes.map((i) => props.variantPlanIds![i] || "")
|
||||
: []
|
||||
const hasVariantPlans = variantPlansArr.some((id) => !!id)
|
||||
|
||||
try {
|
||||
const taskResp = await createGenerationTask({
|
||||
template_id: selectedTemplate,
|
||||
asset_ids: assetIds,
|
||||
output_width: outputWidth,
|
||||
output_height: outputHeight,
|
||||
cover_url: coverUrl,
|
||||
custom_title: props.titleSettings?.title || "",
|
||||
duration: props.duration || undefined,
|
||||
video_ratio: props.videoRatio,
|
||||
assembly_mode: editMode,
|
||||
...(editMode === "narrative" && props.selectedScript?.id
|
||||
? {
|
||||
script_id: props.selectedScript.id,
|
||||
tts_voice_id: props.ttsVoiceId || undefined,
|
||||
tts_voice_source: props.ttsVoiceSource || undefined,
|
||||
tts_style: props.ttsStyle || undefined,
|
||||
}
|
||||
: {}),
|
||||
dedup_enabled: dedupEnabled,
|
||||
voice_library_id: voiceLibraryId,
|
||||
...(props.selectedVoice && !voiceLibraryId ? { voice_ids: [props.selectedVoice] } : {}),
|
||||
bgm_config: {
|
||||
enabled: props.bgm !== false,
|
||||
...(props.bgmConfig?.music_id ? { preset_id: props.bgmConfig.music_id } : {}),
|
||||
},
|
||||
...(props.sourceEditPlanId ? { source_edit_plan_id: props.sourceEditPlanId } : {}),
|
||||
...(isBatch ? { count: batchCount } : {}),
|
||||
...(titlesArr.length ? { titles: titlesArr } : {}),
|
||||
...(voiceArr.length ? { voice_library_ids: voiceArr } : {}),
|
||||
...(coversArr.length ? { cover_urls: coversArr } : {}),
|
||||
...(hasVariantPlans ? { variant_plan_ids: variantPlansArr } : {}),
|
||||
...(props.titleSettings?.title
|
||||
? {
|
||||
title_config: {
|
||||
text: props.titleSettings.title,
|
||||
font: props.titleSettings.font,
|
||||
font_size: props.titleSettings.size,
|
||||
font_color: props.titleSettings.color,
|
||||
position: props.titleSettings.position,
|
||||
...(props.titleSettings.position === "custom" &&
|
||||
props.titleSettings.posX != null &&
|
||||
props.titleSettings.posY != null
|
||||
? {
|
||||
pos_x: Math.round(props.titleSettings.posX),
|
||||
pos_y: Math.round(props.titleSettings.posY),
|
||||
}
|
||||
: {}),
|
||||
bold: props.titleSettings.bold,
|
||||
italic: props.titleSettings.italic,
|
||||
stroke: props.titleSettings.stroke
|
||||
? {
|
||||
enabled: true,
|
||||
width: props.titleSettings.strokeWidth ?? 4,
|
||||
color: props.titleSettings.strokeColor ?? "#000000",
|
||||
}
|
||||
: { enabled: false },
|
||||
shadow: props.titleSettings.shadow
|
||||
? {
|
||||
enabled: true,
|
||||
offset_x: props.titleSettings.shadowOffsetX ?? 2,
|
||||
offset_y: props.titleSettings.shadowOffsetY ?? 2,
|
||||
blur: props.titleSettings.shadowBlur ?? 4,
|
||||
color: props.titleSettings.shadowColor ?? "rgba(0,0,0,0.8)",
|
||||
}
|
||||
: { enabled: false },
|
||||
line_height: props.titleSettings.lineHeight ?? 1.2,
|
||||
margin_top: props.titleSettings.marginTop ?? 24,
|
||||
max_chars_per_line: props.titleSettings.maxCharsPerLine ?? 0,
|
||||
...(props.titleSettings.bgEnabled
|
||||
? {
|
||||
background: {
|
||||
enabled: true,
|
||||
color: props.titleSettings.bgColor,
|
||||
padding: props.titleSettings.bgPadding,
|
||||
radius: props.titleSettings.bgRadius,
|
||||
},
|
||||
}
|
||||
: { background: { enabled: false } }),
|
||||
line_overrides: (props.titleSettings.lineOverrides ?? []).map((lo) => ({
|
||||
line_index: lo.line_index,
|
||||
text: lo.text,
|
||||
size: lo.size,
|
||||
color: lo.color,
|
||||
bold: lo.bold,
|
||||
italic: lo.italic,
|
||||
stroke: lo.stroke,
|
||||
highlights: lo.highlights?.map((h) => ({
|
||||
word: h.word,
|
||||
color: h.color,
|
||||
bold: h.bold,
|
||||
scale: h.scale,
|
||||
})),
|
||||
})),
|
||||
...(props.titleSettings.coverTitle
|
||||
? {
|
||||
cover_title_config: {
|
||||
title: props.titleSettings.coverTitle.title,
|
||||
font: props.titleSettings.coverTitle.font,
|
||||
font_size: props.titleSettings.coverTitle.size,
|
||||
font_color: props.titleSettings.coverTitle.color,
|
||||
bold: props.titleSettings.coverTitle.bold,
|
||||
italic: props.titleSettings.coverTitle.italic,
|
||||
position: props.titleSettings.coverTitle.position,
|
||||
stroke: props.titleSettings.coverTitle.stroke
|
||||
? {
|
||||
enabled: true,
|
||||
width: props.titleSettings.coverTitle.strokeWidth ?? 4,
|
||||
color: props.titleSettings.coverTitle.strokeColor ?? "#000000",
|
||||
}
|
||||
: { enabled: false },
|
||||
shadow: props.titleSettings.coverTitle.shadow
|
||||
? {
|
||||
enabled: true,
|
||||
offset_x: props.titleSettings.coverTitle.shadowOffsetX ?? 2,
|
||||
offset_y: props.titleSettings.coverTitle.shadowOffsetY ?? 2,
|
||||
blur: props.titleSettings.coverTitle.shadowBlur ?? 4,
|
||||
color:
|
||||
props.titleSettings.coverTitle.shadowColor ?? "rgba(0,0,0,0.8)",
|
||||
}
|
||||
: { enabled: false },
|
||||
...(props.titleSettings.coverTitle.bgEnabled
|
||||
? {
|
||||
background: {
|
||||
enabled: true,
|
||||
color: props.titleSettings.coverTitle.bgColor,
|
||||
padding: props.titleSettings.coverTitle.bgPadding,
|
||||
radius: props.titleSettings.coverTitle.bgRadius,
|
||||
},
|
||||
}
|
||||
: { background: { enabled: false } }),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
})
|
||||
hide()
|
||||
const taskIds = (taskResp.items || []).map((it) => it.id).filter(Boolean)
|
||||
|
||||
if (taskIds.length === 0) {
|
||||
throw new Error("创建任务成功但未返回任务 ID,请稍后在任务列表查看")
|
||||
}
|
||||
if (taskIds.length > 1) {
|
||||
// 批量:任务按创建顺序与勾选变体一一对应(后端按 count 顺序创建)
|
||||
setCurrentTaskId("")
|
||||
startPollingBatch(taskIds.map((taskId, i) => ({ taskId, variantIndex: indexes[i] ?? i })))
|
||||
} else {
|
||||
if (!isBatch) {
|
||||
/* ── 单视频:原逻辑(一次提交 count=1) ── */
|
||||
const hide = message.loading("正在生成预览视频...", 0)
|
||||
try {
|
||||
const taskResp = await createGenerationTask({ ...basePayload, count: 1 })
|
||||
hide()
|
||||
const taskIds = (taskResp.items || []).map((it) => it.id).filter(Boolean)
|
||||
if (taskIds.length === 0) {
|
||||
throw new Error("创建任务成功但未返回任务 ID,请稍后在任务列表查看")
|
||||
}
|
||||
setCurrentTaskId(taskIds[0])
|
||||
startPolling(taskIds[0])
|
||||
} catch (err) {
|
||||
hide()
|
||||
throw err
|
||||
}
|
||||
} catch (err) {
|
||||
hide()
|
||||
throw err
|
||||
return true
|
||||
}
|
||||
|
||||
/* ── 批量:支持任意数量视频,按队列容量串行提交,429 自动排队重试 ── */
|
||||
const indexes = props.selectedVariantIndexes?.length
|
||||
? props.selectedVariantIndexes
|
||||
: Array.from({ length: props.previewCount || 1 }, (_, i) => i)
|
||||
const batchCount = indexes.length
|
||||
|
||||
const titlesAll =
|
||||
(props.variantTitles?.length || 0) >= batchCount
|
||||
? indexes.map((i) => props.variantTitles![i] || props.titleSettings?.title || "")
|
||||
: indexes.map(() => props.titleSettings?.title || "")
|
||||
const voiceArrAll =
|
||||
props.voiceModePerVideo && props.variantVoiceLibraryIds?.length
|
||||
? indexes.map(
|
||||
(i) => props.variantVoiceLibraryIds![i] || basePayload.voice_library_id || "",
|
||||
)
|
||||
: []
|
||||
const coversAll = props.variantCoverUrls?.length
|
||||
? indexes.map((i) => props.variantCoverUrls![i] || "")
|
||||
: indexes.map(() => "")
|
||||
const plansAll = props.variantPlanIds?.length
|
||||
? indexes.map((i) => props.variantPlanIds![i] || "")
|
||||
: indexes.map(() => "")
|
||||
|
||||
const hasAnyVoice = voiceArrAll.some((v) => !!v)
|
||||
const hasAnyCover = coversAll.some((u) => !!u)
|
||||
const hasAnyPlan = plansAll.some((id) => !!id)
|
||||
|
||||
// 先用占位 ID 把所有变体卡片置为 queued,UI 可见
|
||||
const placeholderIds = indexes.map((_, i) => `__queued_${Date.now()}_${i}`)
|
||||
const initialTasks: BatchTaskState[] = indexes.map((variantIndex, i) => ({
|
||||
taskId: placeholderIds[i],
|
||||
variantIndex,
|
||||
status: "queued",
|
||||
progress: 0,
|
||||
error: null,
|
||||
videos: [],
|
||||
}))
|
||||
setBatchTasks(initialTasks)
|
||||
|
||||
message.loading({
|
||||
content: `已提交 ${batchCount} 个视频任务,系统按队列容量依次渲染…`,
|
||||
key: "batch-gen",
|
||||
duration: 3,
|
||||
})
|
||||
|
||||
/** 将占位 taskId 更新为真实 taskId(卡片引用同一对象) */
|
||||
const replacePlaceholder = (placeholderId: string, realTaskId: string) => {
|
||||
setBatchTasks((prev) => {
|
||||
const idx = prev.findIndex((t) => t.taskId === placeholderId)
|
||||
if (idx === -1) return prev
|
||||
const next = [...prev]
|
||||
next[idx] = { ...next[idx], taskId: realTaskId }
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
/** 提交某一索引的单任务(count=1),成功后返回真实 taskId;429/503 则返回 waitMs */
|
||||
const submitOne = async (
|
||||
i: number,
|
||||
): Promise<{ queued: true; waitMs: number } | { queued: false; taskId: string }> => {
|
||||
const body: CreateGenerationTaskRequest = {
|
||||
...basePayload,
|
||||
count: 1,
|
||||
titles: [titlesAll[i] || ""],
|
||||
...(hasAnyVoice
|
||||
? { voice_library_ids: [voiceArrAll[i] || basePayload.voice_library_id || ""] }
|
||||
: {}),
|
||||
...(hasAnyCover ? { cover_urls: [coversAll[i] || ""] } : {}),
|
||||
...(hasAnyPlan && plansAll[i] ? { variant_plan_ids: [plansAll[i]] } : {}),
|
||||
}
|
||||
try {
|
||||
const resp = await createGenerationTask(body)
|
||||
const item = resp.items?.[0]
|
||||
const tid = item?.id
|
||||
if (!tid) throw new Error("创建任务成功但未返回任务 ID")
|
||||
return { queued: false, taskId: tid }
|
||||
} catch (err) {
|
||||
const q = isUserQueueFullError(err)
|
||||
if (q) return { queued: true, waitMs: q.waitMs }
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
// 串行提交:每次提交一个;429/503 则等待后重试;其它错误立即标记该任务失败
|
||||
let fatalErr: unknown = null
|
||||
for (let i = 0; i < batchCount; i++) {
|
||||
if (cancelledRef.current) return false
|
||||
const variantIndex = indexes[i]
|
||||
const placeholderId = placeholderIds[i]
|
||||
let attempt = 0
|
||||
let submitted = false
|
||||
while (!submitted) {
|
||||
if (cancelledRef.current) return false
|
||||
attempt++
|
||||
try {
|
||||
const result = await submitOne(i)
|
||||
if (!result.queued) {
|
||||
replacePlaceholder(placeholderId, result.taskId)
|
||||
// 先更新到 running,再启动单任务增量轮询(不触发整体 onComplete)
|
||||
pollBatchTaskQueued(result.taskId, variantIndex)
|
||||
submitted = true
|
||||
} else {
|
||||
// 排队:保持 queued 状态,等待后重试
|
||||
handleBatchTaskUpdate(placeholderId, {
|
||||
taskId: placeholderId,
|
||||
variantIndex,
|
||||
status: "queued",
|
||||
progress: 0,
|
||||
error: null,
|
||||
})
|
||||
if (attempt === 1) {
|
||||
message.info({
|
||||
content: `队列繁忙,${Math.round(result.waitMs / 1000)} 秒后自动继续提交后续视频…`,
|
||||
key: "batch-gen",
|
||||
duration: 4,
|
||||
})
|
||||
}
|
||||
await sleep(Math.min(result.waitMs, 60_000))
|
||||
}
|
||||
} catch (err) {
|
||||
// 非限流错误:该任务标记失败,继续后续任务(不阻断整个批量)
|
||||
console.error("[batch generate] 任务提交失败:", err)
|
||||
const msg = translateError(extractBackendError(err))
|
||||
handleBatchTaskUpdate(placeholderId, {
|
||||
taskId: placeholderId,
|
||||
variantIndex,
|
||||
status: "failed",
|
||||
error: msg,
|
||||
progress: 0,
|
||||
})
|
||||
submitted = true
|
||||
if (!fatalErr) fatalErr = err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (fatalErr) {
|
||||
// 有任务失败但其余已成功,整体不 throw;由 UI 展示单个失败卡片
|
||||
}
|
||||
return true
|
||||
} catch (err: unknown) {
|
||||
console.error("[handleGenerate] 生成失败:", err)
|
||||
setGenerating(false)
|
||||
const backendMsg = extractBackendError(err)
|
||||
console.error("[handleGenerate] 错误信息:", backendMsg, "完整错误:", err)
|
||||
const finalMsg = translateError(backendMsg)
|
||||
setGenerateError(finalMsg)
|
||||
setGenerating(false)
|
||||
message.error(finalMsg)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}, [props, clearTimer, startPolling, startPollingBatch, selectedTemplate])
|
||||
}, [
|
||||
props,
|
||||
clearTimer,
|
||||
startPolling,
|
||||
selectedTemplate,
|
||||
buildBasePayload,
|
||||
handleBatchTaskUpdate,
|
||||
clearQueueTimers,
|
||||
pollBatchTaskQueued,
|
||||
])
|
||||
|
||||
const retry = useCallback(() => {
|
||||
setGenerateError(null)
|
||||
@@ -383,9 +554,10 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
|
||||
/** 第5步:单独重试某个失败任务 */
|
||||
const retryBatchTask = useCallback(
|
||||
(taskId: string) => {
|
||||
handleBatchTaskUpdate(taskId, { status: "running", progress: 0, error: null, videos: [] })
|
||||
retryTask(taskId)
|
||||
},
|
||||
[retryTask],
|
||||
[retryTask, handleBatchTaskUpdate],
|
||||
)
|
||||
|
||||
const dismissError = useCallback(() => {
|
||||
|
||||
Reference in New Issue
Block a user