fix(generate): 修复生成进度条卡56%不刷新 P1 #2071
@@ -494,6 +494,49 @@ const GeneratePage: React.FC = () => {
|
||||
navigate,
|
||||
])
|
||||
|
||||
/* ── 生成完成后自动跳转封面选择页(修复进度条卡 56% 看不到结果的体验问题) ──
|
||||
单视频:生成完成且有 finalVideo 时延迟 800ms 自动跳 step5
|
||||
批量:所有任务完成(无 running 项)且至少有一个成功时延迟 800ms 跳转
|
||||
失败/用户手动点重试时不触发(progress 回到 0 或 generating=false 且无 finalVideo)
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (currentStep !== 4) return
|
||||
if (generating) return
|
||||
if (generateError) return
|
||||
if (!generated) return
|
||||
|
||||
let shouldJump = false
|
||||
if (isBatch) {
|
||||
const allDone =
|
||||
batchTasks.length > 0 &&
|
||||
batchTasks.every(
|
||||
(t) => t.status === "completed" || t.status === "awaiting_cover" || t.status === "failed",
|
||||
)
|
||||
const anySuccess = batchTasks.some(
|
||||
(t) => t.status === "completed" || t.status === "awaiting_cover",
|
||||
)
|
||||
shouldJump = allDone && anySuccess
|
||||
} else {
|
||||
shouldJump = !!(finalVideo || currentTaskId)
|
||||
}
|
||||
if (!shouldJump) return
|
||||
|
||||
const t = setTimeout(() => {
|
||||
if (currentStep === 4) setCurrentStep(5)
|
||||
}, 800)
|
||||
return () => clearTimeout(t)
|
||||
}, [
|
||||
currentStep,
|
||||
generating,
|
||||
generateError,
|
||||
generated,
|
||||
isBatch,
|
||||
batchTasks,
|
||||
finalVideo,
|
||||
currentTaskId,
|
||||
setCurrentStep,
|
||||
])
|
||||
|
||||
/* ── 布局 class ── */
|
||||
const layoutClassName = "xx-generate-layout full-width"
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useRef, useCallback, useState } from "react"
|
||||
import { useRef, useCallback, useState, useEffect } from "react"
|
||||
import { message } from "antd"
|
||||
import axios from "axios"
|
||||
import { getGenerationTask, retryTask as retryGenerationTaskApi } from "@/api/tasks/tasks"
|
||||
@@ -25,19 +25,80 @@ interface UseGenerationPollingOptions {
|
||||
onBatchTaskUpdate?: (taskId: string, patch: Partial<BatchTaskState>) => void
|
||||
}
|
||||
|
||||
/** 最大连续错误次数(仅对可重试错误),超过后终止轮询 */
|
||||
const MAX_RETRYABLE_ERRORS = 10
|
||||
/** 获取结果的最大重试次数 */
|
||||
/** 获取结果的最大重试次数(5xx/网络错误时;4xx 直接放弃) */
|
||||
const MAX_RESULTS_RETRIES = 3
|
||||
|
||||
/** 轮询基础间隔(毫秒) */
|
||||
const BASE_POLL_INTERVAL_MS = 2000
|
||||
/** 初始启动延迟 */
|
||||
const INITIAL_DELAY_MS = 1000
|
||||
/** 网络/5xx 错误时的退避基础间隔(指数退避,最大 15s) */
|
||||
const NETWORK_ERROR_BASE_MS = 2000
|
||||
const NETWORK_ERROR_MAX_MS = 15000
|
||||
/** 连续网络错误达到阈值弹一次提示(避免 toast 风暴) */
|
||||
const NETWORK_ERROR_NOTICE_THRESHOLD = 5
|
||||
/** 进度长时间无变化时,缩短轮询间隔主动探测,防止后端最后一次 progress 回调丢失 */
|
||||
const STALL_PROBE_MS = 15000
|
||||
|
||||
/**
|
||||
* 生成状态轮询 Hook(v4 — 批量任务独立状态 + 单任务重试)
|
||||
* 把后端返回的任务状态字符串归一化到前端处理的状态。
|
||||
* 兼容历史脏数据/后端新增状态(pending/queued/scheduled 等),避免出现"未知状态→既不更新进度也不终止"的卡死。
|
||||
*/
|
||||
function normalizeTaskStatus(
|
||||
raw: string | undefined,
|
||||
): "completed" | "awaiting_cover" | "failed" | "cancelled" | "running" | "pending" | "unknown" {
|
||||
if (!raw) return "unknown"
|
||||
const s = String(raw).trim().toLowerCase()
|
||||
if (
|
||||
s === "completed" ||
|
||||
s === "success" ||
|
||||
s === "done" ||
|
||||
s === "finished" ||
|
||||
s === "complete"
|
||||
) {
|
||||
return "completed"
|
||||
}
|
||||
if (
|
||||
s === "awaiting_cover" ||
|
||||
s === "waiting_cover" ||
|
||||
s === "pending_cover" ||
|
||||
s === "video_ready" ||
|
||||
s === "rendered"
|
||||
) {
|
||||
return "awaiting_cover"
|
||||
}
|
||||
if (s === "failed" || s === "error" || s === "err" || s === "fail") return "failed"
|
||||
if (s === "cancelled" || s === "canceled") return "cancelled"
|
||||
if (
|
||||
s === "running" ||
|
||||
s === "processing" ||
|
||||
s === "in_progress" ||
|
||||
s === "rendering" ||
|
||||
s === "process"
|
||||
) {
|
||||
return "running"
|
||||
}
|
||||
if (s === "pending" || s === "queued" || s === "scheduled") return "pending"
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成状态轮询 Hook(v5 — 修复进度卡 56% 的 P1 bug)
|
||||
*
|
||||
* startPolling(taskId) 轮询单个任务;
|
||||
* startPollingBatch(tasks) 并行轮询 N 个任务:
|
||||
* - 每个任务独立进度/状态/失败,通过 onBatchTaskUpdate 实时回传
|
||||
* - 全部成功才 onComplete(聚合视频按变体顺序);任一失败不影响其他任务继续
|
||||
* - retryTask(taskId) 单独重试失败任务(重新轮询,后端任务仍在跑则直接接续)
|
||||
* v5 修复点(2026-09-27):
|
||||
* 1. pending/queued/unknown 状态兼容:旧版只处理 completed/awaiting_cover/failed/cancelled/running,
|
||||
* 任务刚入队(pending)或后端新增状态时会走到 fallback 分支,但 progress 可能停在上一次值,
|
||||
* 视觉上"卡"在 56%。新版本 pending 显示 ≤5%,unknown 保持上次进度继续轮询,不阻断。
|
||||
* 2. 网络/5xx 错误无限重试+指数退避:旧版连续 10 次错误就终止轮询。新版本只在 4xx(任务不存在/
|
||||
* 未授权)时终止,其他错误指数退避重试,连续错误达到阈值弹一次"网络不稳定"提示。
|
||||
* 3. 页面可见性恢复主动 poll:浏览器后台 tab 会被节流 setTimeout,用户切回标签立即触发一次 poll,
|
||||
* 无需等下一个定时器,感知"秒跳"到完成状态。
|
||||
* 4. 进度停滞探测:若 STALL_PROBE_MS 内进度无变化但任务仍 running,缩短到 3s 主动再查一次,
|
||||
* 防止后端最后一次 progress/status 更新丢失。
|
||||
* 5. unmount 时清理所有定时器:useEffect cleanup 兜底,避免组件卸载后残留定时器触发 setState 警告。
|
||||
* 6. 终态前强制推 100%:completed/awaiting_cover 分支先 onProgress(100) 再 onComplete,避免 UI 停在 95%。
|
||||
* 7. 终态 results 接口抖动兜底:awaiting_cover 时 results 可能因 write-after-read 返回空,
|
||||
* 补一次 1.5s 后的重试再判定失败。
|
||||
*/
|
||||
export function useGenerationPolling({
|
||||
onProgress,
|
||||
@@ -49,21 +110,93 @@ export function useGenerationPolling({
|
||||
const cancelledRef = useRef(false)
|
||||
/** 批量任务上下文:taskId → 变体序号 */
|
||||
const batchContextRef = useRef<Map<string, number>>(new Map())
|
||||
/** 当前单任务轮询的 taskId(供 visibilitychange 快速调度) */
|
||||
const activeTaskRef = useRef<string | null>(null)
|
||||
/** 当前批量轮询的 tasks(供 visibilitychange 全量触发一次) */
|
||||
const activeBatchRef = useRef<{ taskId: string; variantIndex: number }[]>([])
|
||||
/** 批量下每个 task 独立的调度控制对象 */
|
||||
const batchControllersRef = useRef<
|
||||
Map<
|
||||
string,
|
||||
{
|
||||
done: boolean
|
||||
schedule: (delay: number) => void
|
||||
}
|
||||
>
|
||||
>(new Map())
|
||||
/** 单任务 controller(供 visibilitychange 使用) */
|
||||
const singleTaskScheduleRef = useRef<{ done: boolean; schedule: (delay: number) => void } | null>(
|
||||
null,
|
||||
)
|
||||
const [, forceTick] = useState(0)
|
||||
|
||||
const clearTimer = useCallback(() => {
|
||||
cancelledRef.current = true
|
||||
const _clearAllTimers = useCallback(() => {
|
||||
progressTimer.current.forEach((t) => clearTimeout(t))
|
||||
progressTimer.current = []
|
||||
}, [])
|
||||
|
||||
/** 任务完成后拉取结果列表,带重试 */
|
||||
const clearTimer = useCallback(() => {
|
||||
cancelledRef.current = true
|
||||
_clearAllTimers()
|
||||
batchControllersRef.current.clear()
|
||||
singleTaskScheduleRef.current = null
|
||||
activeTaskRef.current = null
|
||||
activeBatchRef.current = []
|
||||
}, [_clearAllTimers])
|
||||
|
||||
/** unmount 兜底清理 */
|
||||
useEffect(() => {
|
||||
const controllers = batchControllersRef
|
||||
const singleCtrl = singleTaskScheduleRef
|
||||
return () => {
|
||||
cancelledRef.current = true
|
||||
_clearAllTimers()
|
||||
controllers.current.clear()
|
||||
singleCtrl.current = null
|
||||
}
|
||||
}, [_clearAllTimers])
|
||||
|
||||
/** 页面从后台切回时,立即触发一次 poll(避免浏览器后台 tab 节流导致的延迟) */
|
||||
useEffect(() => {
|
||||
const handleVisibility = () => {
|
||||
if (document.visibilityState !== "visible") return
|
||||
if (cancelledRef.current) return
|
||||
// 单任务
|
||||
if (
|
||||
activeTaskRef.current &&
|
||||
singleTaskScheduleRef.current &&
|
||||
!singleTaskScheduleRef.current.done
|
||||
) {
|
||||
const t = setTimeout(() => {
|
||||
singleTaskScheduleRef.current?.schedule(0)
|
||||
}, 0)
|
||||
progressTimer.current.push(t)
|
||||
}
|
||||
// 批量
|
||||
activeBatchRef.current.forEach(({ taskId }) => {
|
||||
const ctrl = batchControllersRef.current.get(taskId)
|
||||
if (ctrl && !ctrl.done) {
|
||||
const t = setTimeout(() => ctrl.schedule(0), 0)
|
||||
progressTimer.current.push(t)
|
||||
}
|
||||
})
|
||||
}
|
||||
document.addEventListener("visibilitychange", handleVisibility)
|
||||
return () => document.removeEventListener("visibilitychange", handleVisibility)
|
||||
}, [])
|
||||
|
||||
/** 任务完成后拉取结果列表,带重试(网络/5xx 重试,4xx 直接放弃) */
|
||||
const fetchResultsWithRetry = useCallback(
|
||||
async (taskId: string, attempt = 0): Promise<unknown[] | null> => {
|
||||
try {
|
||||
return await getGenerationTaskResults(taskId)
|
||||
} catch (err) {
|
||||
if (cancelledRef.current) return null
|
||||
const status = axios.isAxiosError(err) ? err.response?.status : undefined
|
||||
if (status && status >= 400 && status < 500) {
|
||||
console.warn(`[获取结果4xx,放弃重试] taskId=${taskId} status=${status}`)
|
||||
return null
|
||||
}
|
||||
console.error(`[获取生成结果失败] 第 ${attempt + 1} 次`, err)
|
||||
if (attempt < MAX_RESULTS_RETRIES - 1) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000 * (attempt + 1)))
|
||||
@@ -87,8 +220,6 @@ export function useGenerationPolling({
|
||||
|
||||
/**
|
||||
* 轮询单个任务。
|
||||
* - isBatch=true:状态变化通过 onBatchTaskUpdate 回传,不触发整体 onProgress/onComplete
|
||||
* - resolve(videos) 成功;reject(Error) 失败
|
||||
*/
|
||||
const pollSingleTask = useCallback(
|
||||
(
|
||||
@@ -101,8 +232,54 @@ export function useGenerationPolling({
|
||||
},
|
||||
): Promise<unknown[]> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
let consecutiveErrors = 0
|
||||
let done = false
|
||||
let consecutiveErrors = 0
|
||||
let lastProgress = 0
|
||||
let lastProgressAt = Date.now()
|
||||
let hasNoticedNetworkError = false
|
||||
|
||||
const finish = (videos: unknown[], taskStatus: "completed" | "awaiting_cover") => {
|
||||
if (done) return
|
||||
done = true
|
||||
callbacks?.onTaskProgress?.(100)
|
||||
if (!callbacks && runId === 0) {
|
||||
onProgress(100)
|
||||
}
|
||||
callbacks?.onTaskCompleted?.(videos, taskStatus)
|
||||
resolve(videos)
|
||||
}
|
||||
|
||||
const fail = (msg: string) => {
|
||||
if (done) return
|
||||
done = true
|
||||
callbacks?.onTaskFailed?.(msg)
|
||||
reject(new Error(msg))
|
||||
}
|
||||
|
||||
const scheduleNext = (delay: number) => {
|
||||
if (cancelledRef.current || done) return
|
||||
const timeSinceProgress = Date.now() - lastProgressAt
|
||||
// 停滞探测:长时间进度不变且是正常轮询间隔,缩短到 3s 主动再查一次
|
||||
const actualDelay =
|
||||
delay === BASE_POLL_INTERVAL_MS && timeSinceProgress > STALL_PROBE_MS ? 3000 : delay
|
||||
const timer = setTimeout(poll, actualDelay)
|
||||
progressTimer.current.push(timer)
|
||||
}
|
||||
|
||||
// 暴露 schedule 给 visibilitychange 使用
|
||||
const scheduleApi = {
|
||||
get done() {
|
||||
return done
|
||||
},
|
||||
schedule: (d: number) => {
|
||||
if (!done) scheduleNext(d)
|
||||
},
|
||||
}
|
||||
if (!callbacks && runId === 0) {
|
||||
singleTaskScheduleRef.current = scheduleApi
|
||||
} else {
|
||||
batchControllersRef.current.set(taskId, scheduleApi)
|
||||
}
|
||||
|
||||
const poll = async () => {
|
||||
if (cancelledRef.current || done) return
|
||||
@@ -111,65 +288,75 @@ export function useGenerationPolling({
|
||||
if (cancelledRef.current || done) return
|
||||
consecutiveErrors = 0
|
||||
|
||||
if (task.status === "completed" || task.status === "awaiting_cover") {
|
||||
done = true
|
||||
const videos = await fetchResultsWithRetry(taskId)
|
||||
const status = normalizeTaskStatus(task.status)
|
||||
|
||||
if (status === "completed" || status === "awaiting_cover") {
|
||||
let videos = await fetchResultsWithRetry(taskId)
|
||||
if (cancelledRef.current) return
|
||||
if (videos === null) {
|
||||
const msg = "视频已生成,但获取结果列表失败,请稍后在任务列表查看"
|
||||
callbacks?.onTaskFailed?.(msg)
|
||||
reject(new Error(msg))
|
||||
// results 为空时补一次短延迟重试(write-after-read 抖动)
|
||||
if ((!videos || videos.length === 0) && status === "awaiting_cover") {
|
||||
await new Promise((r) => setTimeout(r, 1500))
|
||||
if (cancelledRef.current || done) return
|
||||
videos = await fetchResultsWithRetry(taskId)
|
||||
}
|
||||
if (videos === null || videos.length === 0) {
|
||||
const msg = "视频已生成,但获取结果列表失败,请刷新页面或稍后在任务列表查看"
|
||||
fail(msg)
|
||||
return
|
||||
}
|
||||
callbacks?.onTaskCompleted?.(videos, task.status as "completed" | "awaiting_cover")
|
||||
resolve(videos)
|
||||
finish(videos, status as "completed" | "awaiting_cover")
|
||||
return
|
||||
}
|
||||
|
||||
if (task.status === "failed" || task.status === "cancelled") {
|
||||
done = true
|
||||
if (status === "failed" || status === "cancelled") {
|
||||
const rawMsg =
|
||||
task.error_info?.error_message ||
|
||||
task.error_message ||
|
||||
(task.status === "cancelled" ? "任务已取消" : "视频生成失败,请联系管理员或重试")
|
||||
(status === "cancelled" ? "任务已取消" : "视频生成失败,请联系管理员或重试")
|
||||
const msg = safeExtractError(rawMsg)
|
||||
callbacks?.onTaskFailed?.(msg)
|
||||
reject(new Error(msg))
|
||||
fail(msg)
|
||||
return
|
||||
}
|
||||
|
||||
const pct = Math.max(0, Math.min(99, Math.round(Number(task.progress) || 0)))
|
||||
// running / pending / unknown:继续轮询
|
||||
const rawPct = Number(task.progress) || 0
|
||||
const pct =
|
||||
status === "pending"
|
||||
? Math.max(0, Math.min(5, Math.round(rawPct)))
|
||||
: Math.max(0, Math.min(99, Math.round(rawPct)))
|
||||
if (pct !== lastProgress) {
|
||||
lastProgress = pct
|
||||
lastProgressAt = Date.now()
|
||||
}
|
||||
callbacks?.onTaskProgress?.(pct)
|
||||
if (!callbacks && runId === 0) {
|
||||
onProgress(pct)
|
||||
}
|
||||
const timer = setTimeout(poll, 2000)
|
||||
progressTimer.current.push(timer)
|
||||
scheduleNext(BASE_POLL_INTERVAL_MS)
|
||||
} catch (pollErr) {
|
||||
if (cancelledRef.current || done) return
|
||||
console.error("[轮询出错] taskId:", taskId, pollErr)
|
||||
const status = axios.isAxiosError(pollErr) ? pollErr.response?.status : undefined
|
||||
if (status && status >= 400 && status < 500) {
|
||||
done = true
|
||||
const msg = extractErrorMessage(pollErr, status)
|
||||
callbacks?.onTaskFailed?.(msg)
|
||||
reject(new Error(msg))
|
||||
fail(msg)
|
||||
return
|
||||
}
|
||||
// 网络/5xx:指数退避重试,永不终止
|
||||
consecutiveErrors += 1
|
||||
if (consecutiveErrors >= MAX_RETRYABLE_ERRORS) {
|
||||
done = true
|
||||
const msg = "任务状态查询连续失败,请稍后在任务列表查看结果"
|
||||
callbacks?.onTaskFailed?.(msg)
|
||||
reject(new Error(msg))
|
||||
return
|
||||
if (consecutiveErrors >= NETWORK_ERROR_NOTICE_THRESHOLD && !hasNoticedNetworkError) {
|
||||
hasNoticedNetworkError = true
|
||||
message.warning("网络不稳定,正在继续查询任务状态…")
|
||||
}
|
||||
const timer = setTimeout(poll, 3000)
|
||||
progressTimer.current.push(timer)
|
||||
const backoff = Math.min(
|
||||
NETWORK_ERROR_BASE_MS * Math.pow(1.5, Math.min(consecutiveErrors - 1, 6)),
|
||||
NETWORK_ERROR_MAX_MS,
|
||||
)
|
||||
scheduleNext(backoff)
|
||||
}
|
||||
}
|
||||
|
||||
const timer = setTimeout(poll, 1500)
|
||||
const timer = setTimeout(poll, INITIAL_DELAY_MS)
|
||||
progressTimer.current.push(timer)
|
||||
})
|
||||
},
|
||||
@@ -180,38 +367,49 @@ export function useGenerationPolling({
|
||||
const startPolling = useCallback(
|
||||
(taskId: string) => {
|
||||
cancelledRef.current = false
|
||||
_clearAllTimers()
|
||||
batchContextRef.current.clear()
|
||||
batchControllersRef.current.clear()
|
||||
activeTaskRef.current = taskId
|
||||
activeBatchRef.current = []
|
||||
singleTaskScheduleRef.current = null
|
||||
pollSingleTask(taskId, 0)
|
||||
.then((videos) => {
|
||||
activeTaskRef.current = null
|
||||
singleTaskScheduleRef.current = null
|
||||
if (cancelledRef.current) return
|
||||
onProgress(100)
|
||||
onComplete(videos)
|
||||
message.success("视频生成完成!")
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
activeTaskRef.current = null
|
||||
singleTaskScheduleRef.current = null
|
||||
if (cancelledRef.current) return
|
||||
console.error("[生成失败] taskId:", taskId, err.message)
|
||||
onFailed(err.message)
|
||||
message.error(err.message)
|
||||
})
|
||||
},
|
||||
[pollSingleTask, onProgress, onComplete, onFailed],
|
||||
[pollSingleTask, onProgress, onComplete, onFailed, _clearAllTimers],
|
||||
)
|
||||
|
||||
/**
|
||||
* 批量多任务轮询:
|
||||
* - 每个任务独立进度/状态回传 onBatchTaskUpdate
|
||||
* * 全部完成后按变体顺序聚合视频 onComplete
|
||||
* - 部分失败:整体不 onFailed(第5步逐卡片展示失败+重试按钮);全部失败才 onFailed
|
||||
* 批量多任务轮询
|
||||
*/
|
||||
const startPollingBatch = useCallback(
|
||||
(tasks: { taskId: string; variantIndex: number }[]) => {
|
||||
cancelledRef.current = false
|
||||
_clearAllTimers()
|
||||
batchControllersRef.current.clear()
|
||||
singleTaskScheduleRef.current = null
|
||||
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]))
|
||||
activeBatchRef.current = [...tasks]
|
||||
activeTaskRef.current = null
|
||||
|
||||
const reportAggregateProgress = () => {
|
||||
if (cancelledRef.current) return
|
||||
@@ -228,7 +426,6 @@ export function useGenerationPolling({
|
||||
onComplete(ordered)
|
||||
message.success(`全部 ${tasks.length} 个视频生成完成!`)
|
||||
} else if (resultMap.size > 0) {
|
||||
// 部分失败:成功的视频聚合进成片列表(可进封面),失败卡片带重试按钮
|
||||
onProgress(100)
|
||||
const ordered = tasks
|
||||
.filter((t) => resultMap.has(t.taskId))
|
||||
@@ -273,14 +470,14 @@ export function useGenerationPolling({
|
||||
checkAllSettled()
|
||||
},
|
||||
}).catch(() => {
|
||||
// 失败已在 onTaskFailed 处理,这里吞掉 Promise rejection
|
||||
// 失败已在 onTaskFailed 处理
|
||||
})
|
||||
})
|
||||
},
|
||||
[pollSingleTask, onProgress, onComplete, onFailed, onBatchTaskUpdate],
|
||||
[pollSingleTask, onProgress, onComplete, onFailed, onBatchTaskUpdate, _clearAllTimers],
|
||||
)
|
||||
|
||||
/** 单独重试失败任务(第5步卡片「重试此视频」):先调后端重试接口,再轮询 */
|
||||
/** 单独重试失败任务(第5步卡片「重试此视频」) */
|
||||
const retryTask = useCallback(
|
||||
async (taskId: string) => {
|
||||
if (cancelledRef.current) cancelledRef.current = false
|
||||
@@ -289,9 +486,9 @@ export function useGenerationPolling({
|
||||
try {
|
||||
await retryGenerationTaskApi(taskId)
|
||||
} catch (err) {
|
||||
// 后端不支持重试或任务不可重试:直接重新轮询(任务可能已被自动恢复)
|
||||
console.warn("[重试任务接口调用失败,改为直接轮询]", err)
|
||||
}
|
||||
batchControllersRef.current.delete(taskId)
|
||||
pollSingleTask(taskId, Date.now(), {
|
||||
onTaskProgress: (pct) => onBatchTaskUpdate?.(taskId, { status: "running", progress: pct }),
|
||||
onTaskCompleted: (videos, taskStatus) => {
|
||||
|
||||
Reference in New Issue
Block a user