From 0a004db1bdd74c5c6705c729a1d177757fc80f83 Mon Sep 17 00:00:00 2001 From: Coze Agent Date: Tue, 29 Sep 2026 18:01:54 +0800 Subject: [PATCH] =?UTF-8?q?feat(generate):=20=E6=89=B9=E9=87=8F=E7=94=9F?= =?UTF-8?q?=E6=88=90=E6=94=AF=E6=8C=81>3=E8=A7=86=E9=A2=91=E6=8E=92?= =?UTF-8?q?=E9=98=9F=E6=8F=90=E4=BA=A4=20+=20=E4=B8=A4=E5=88=97=E6=A0=87?= =?UTF-8?q?=E9=A2=98=20+=20=E7=8B=AC=E7=AB=8B=E9=85=8D=E9=9F=B3=20(#2096)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 清理。 --- .../components/BatchGenerationGrid.tsx | 27 +- .../components/GenerateStepContent.tsx | 73 +- .../components/Step4TitleSettings.tsx | 32 +- .../generate-video/useGenerationPolling.ts | 41 +- .../pages/generate/hooks/useGenerateVideo.ts | 628 +++++++++++------- 5 files changed, 532 insertions(+), 269 deletions(-) diff --git a/apps/web/src/pages/generate/components/BatchGenerationGrid.tsx b/apps/web/src/pages/generate/components/BatchGenerationGrid.tsx index f9f0a444e..84544eca2 100644 --- a/apps/web/src/pages/generate/components/BatchGenerationGrid.tsx +++ b/apps/web/src/pages/generate/components/BatchGenerationGrid.tsx @@ -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 = ({ className="xx-batch-gen-card-icon" style={{ color: "#ef4444" }} /> + ) : task.status === "queued" ? ( + ) : ( = ({
{Math.round(task.progress)}%
)} + {task.status === "queued" && ( +
+ + 排队等待中,前面任务完成后自动开始渲染 +
+ )} {(task.status === "completed" || task.status === "awaiting_cover") && video && ( // 竖屏自适应容器(#1750):成片固定 1080×1920(9:16), // 视频按真实宽高比 contain 显示,黑底居中,杜绝横屏播放器左右大黑边 diff --git a/apps/web/src/pages/generate/components/GenerateStepContent.tsx b/apps/web/src/pages/generate/components/GenerateStepContent.tsx index 2d29b3904..22af3044f 100644 --- a/apps/web/src/pages/generate/components/GenerateStepContent.tsx +++ b/apps/web/src/pages/generate/components/GenerateStepContent.tsx @@ -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 = (props) = selectedCoverTemplate, onSelectedCoverTemplateChange, onConfirmGenerate, + selectedVoice, + onSelectedVoiceChange, + voiceModePerVideo, + onVoiceModePerVideoChange, + voiceLibraryIds, + onVoiceLibraryIdsChange, } = props switch (currentStep) { @@ -184,32 +191,46 @@ export const GenerateStepContent: React.FC = (props) = ) case 3: return ( - 1 ? props.selectedVariantIds?.length || 1 : 1 - } - /> + <> + 1 + ? props.selectedVariantIds?.length || 1 + : 1 + } + /> + {/* 批量配音选择:共用/独立切换(#2096) */} + + ) case 4: return ( diff --git a/apps/web/src/pages/generate/components/Step4TitleSettings.tsx b/apps/web/src/pages/generate/components/Step4TitleSettings.tsx index 0beb3a5bb..fdd1a1f23 100644 --- a/apps/web/src/pages/generate/components/Step4TitleSettings.tsx +++ b/apps/web/src/pages/generate/components/Step4TitleSettings.tsx @@ -157,7 +157,7 @@ const Step4TitleSettings: React.FC = (props) => { /> ) : ( - /* ── 批量:N 个独立标题输入框 ── */ + /* ── 批量:N 个独立标题输入框(两列布局 #2096) ── */
= (props) => { 为每个视频输入独立标题。标题样式(字体/颜色/位置)全局统一。
- {Array.from({ length: previewCount }, (_, i) => ( -
- - updateVariantTitle(i, val)} - options={titleOptions} - /> -
- ))} +
+ {Array.from({ length: previewCount }, (_, i) => ( +
+ + updateVariantTitle(i, val)} + options={titleOptions} + /> +
+ ))} +
)} diff --git a/apps/web/src/pages/generate/hooks/generate-video/useGenerationPolling.ts b/apps/web/src/pages/generate/hooks/generate-video/useGenerationPolling.ts index f0df149a9..272016768 100644 --- a/apps/web/src/pages/generate/hooks/generate-video/useGenerationPolling.ts +++ b/apps/web/src/pages/generate/hooks/generate-video/useGenerationPolling.ts @@ -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 } } diff --git a/apps/web/src/pages/generate/hooks/useGenerateVideo.ts b/apps/web/src/pages/generate/hooks/useGenerateVideo.ts index 204fdae03..fda29c22a 100755 --- a/apps/web/src/pages/generate/hooks/useGenerateVideo.ts +++ b/apps/web/src/pages/generate/hooks/useGenerateVideo.ts @@ -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((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([]) + /** 排队中重试的定时器,unmount / 新提交时清理 */ + const queueTimersRef = useRef([]) + 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) => { 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() + 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) : {}), + } + + return payload + }, [props, selectedTemplate]) + /* ── 生成视频 ── - 返回 true 表示任务创建成功并已开始轮询;false 表示校验未通过或创建失败 */ + 返回 true 表示任务创建成功并已开始轮询(含排队中);false 表示校验未通过或创建失败 */ const generate = useCallback(async (): Promise => { 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(() => { -- 2.54.0