feat(video-from-script): 严格并行提交批次限制并增强额度错误检测
- 引入 `isQuotaError` 函数,统一检测 API 额度不足错误 - Kling 生成器改为严格分批提交并等待每批轮询完成,限制并发 ≤ 5 - phase-videos 重构为分批提交+轮询模式,支持额度不足时提前终止 - 提取 `applyPollResult` 和 `pollOpts` 工具函数,减少代码重复 - 新增批量提交的进度日志,显示当前批次和总数
This commit is contained in:
@@ -388,8 +388,14 @@ async function generate(imageUrl, prompt, options = {}) {
|
||||
// 批量并行生成(支持 manifest.json)
|
||||
// ============================================================================
|
||||
|
||||
function isQuotaError(msg) {
|
||||
if (!msg) return false
|
||||
const s = msg.toLowerCase()
|
||||
return /quota|limit|exceed|insufficient|余额|额度|超限|rate.?limit|too.?many/.test(s)
|
||||
}
|
||||
|
||||
async function batchGenerate(tasks, options = {}) {
|
||||
const { outputDir = './output', concurrency = 2, duration = 5, mode = 'std' } = options
|
||||
const { outputDir = './output', concurrency = 5, duration = 5, mode = 'std' } = options
|
||||
|
||||
fs.mkdirSync(outputDir, { recursive: true })
|
||||
|
||||
@@ -406,14 +412,19 @@ async function batchGenerate(tasks, options = {}) {
|
||||
}))
|
||||
}
|
||||
|
||||
// Phase 1: 并行提交
|
||||
// 严格并发 ≤ 5:每批提交后等轮询完成再提交下一批
|
||||
const batchSize = Math.min(concurrency, 5)
|
||||
const modeLabel = tasks.some(t => t.lastFrameUrl) ? '首尾帧' : '单图'
|
||||
console.log(`\n📡 并行提交 ${tasks.length} 个可灵视频任务(并发: ${concurrency},模式: ${modeLabel})...`)
|
||||
console.log(`\n📡 并行提交 ${tasks.length} 个可灵视频任务(并发: ${batchSize},模式: ${modeLabel})...`)
|
||||
|
||||
const submitted = []
|
||||
for (let i = 0; i < tasks.length; i += concurrency) {
|
||||
const batch = tasks.slice(i, i + concurrency)
|
||||
const batchResults = await Promise.allSettled(
|
||||
const results = []
|
||||
|
||||
for (let i = 0; i < tasks.length; i += batchSize) {
|
||||
const batch = tasks.slice(i, i + batchSize)
|
||||
const batchLabel = `[${i + 1}-${Math.min(i + batchSize, tasks.length)}/${tasks.length}]`
|
||||
|
||||
// 提交本批
|
||||
const submitResults = await Promise.allSettled(
|
||||
batch.map(async (task, j) => {
|
||||
const idx = i + j
|
||||
const prompt = task.videoPrompt || task.prompt
|
||||
@@ -429,50 +440,54 @@ async function batchGenerate(tasks, options = {}) {
|
||||
}
|
||||
})
|
||||
)
|
||||
submitted.push(...batchResults.map(r => r.value || r.reason))
|
||||
}
|
||||
|
||||
const pendingTasks = submitted.filter(s => s.taskId)
|
||||
const submitted = submitResults.map(r => r.status === 'fulfilled' ? r.value : r.reason)
|
||||
const hitQuota = submitted.some(s => !s.taskId && isQuotaError(s.error))
|
||||
const pendingTasks = submitted.filter(s => s.taskId)
|
||||
|
||||
if (pendingTasks.length === 0) {
|
||||
console.error('\n❌ 所有任务提交失败')
|
||||
return tasks.map((task, idx) => ({
|
||||
success: false, ...task,
|
||||
error: (submitted.find(s => s.idx === idx) || {}).error || '提交失败',
|
||||
}))
|
||||
}
|
||||
|
||||
// Phase 2: 并行轮询
|
||||
console.log(`\n⏳ 并行等待 ${pendingTasks.length} 个视频生成...`)
|
||||
|
||||
const pollResults = await Promise.allSettled(
|
||||
pendingTasks.map(async ({ idx, taskId, task }) => {
|
||||
const prompt = task.videoPrompt || task.prompt
|
||||
const result = await pollWithRetry(taskId, prompt, {
|
||||
outputDir, duration, mode,
|
||||
imageUrl: task.image, lastFrameUrl: task.lastFrameUrl,
|
||||
})
|
||||
return { idx, ...result, task }
|
||||
})
|
||||
)
|
||||
|
||||
// 合并结果
|
||||
const results = []
|
||||
for (let i = 0; i < tasks.length; i++) {
|
||||
const submittedInfo = submitted.find(s => s.idx === i)
|
||||
if (!submittedInfo || !submittedInfo.taskId) {
|
||||
results.push({ success: false, ...tasks[i], error: submittedInfo?.error || '提交失败' })
|
||||
continue
|
||||
// 额度不足时:记录本批失败 + 跳过剩余
|
||||
if (hitQuota) {
|
||||
const remaining = tasks.length - i - batch.length
|
||||
for (const s of submitted) {
|
||||
results.push({ success: false, ...s.task, error: s.error || '提交失败' })
|
||||
}
|
||||
for (let j = i + batchSize; j < tasks.length; j++) {
|
||||
results.push({ success: false, ...tasks[j], error: '额度不足,未提交' })
|
||||
}
|
||||
console.log(`\n⚠️ 额度不足,跳过剩余 ${remaining + submitted.filter(s => !s.taskId).length} 个任务`)
|
||||
break
|
||||
}
|
||||
const pollResult = pollResults.find(r => {
|
||||
if (r.status === 'fulfilled') return r.value.idx === i
|
||||
return false
|
||||
})
|
||||
if (pollResult && pollResult.status === 'fulfilled') {
|
||||
results.push({ success: true, ...tasks[i], ...pollResult.value })
|
||||
} else {
|
||||
const reason = pollResult?.reason?.message || '生成失败'
|
||||
results.push({ success: false, ...tasks[i], error: reason })
|
||||
|
||||
// 本批全部提交失败,跳过轮询
|
||||
for (const s of submitted) {
|
||||
if (!s.taskId) results.push({ success: false, ...s.task, error: s.error || '提交失败' })
|
||||
}
|
||||
if (pendingTasks.length === 0) continue
|
||||
|
||||
// 轮询本批
|
||||
console.log(`\n⏳ ${batchLabel} 等待 ${pendingTasks.length} 个视频生成...`)
|
||||
|
||||
const pollResults = await Promise.allSettled(
|
||||
pendingTasks.map(async ({ idx, taskId, task }) => {
|
||||
const prompt = task.videoPrompt || task.prompt
|
||||
const result = await pollWithRetry(taskId, prompt, {
|
||||
outputDir, duration, mode,
|
||||
imageUrl: task.image, lastFrameUrl: task.lastFrameUrl,
|
||||
})
|
||||
return { idx, ...result, task }
|
||||
})
|
||||
)
|
||||
|
||||
// 合并本批轮询结果
|
||||
for (const s of submitted) {
|
||||
if (!s.taskId) continue
|
||||
const pollResult = pollResults.find(r => r.status === 'fulfilled' && r.value.idx === s.idx)
|
||||
if (pollResult) {
|
||||
results.push({ success: true, ...s.task, ...pollResult.value })
|
||||
} else {
|
||||
const reason = pollResults.find(r => r.value?.idx === s.idx || r.reason?.idx === s.idx)?.reason?.message || '生成失败'
|
||||
results.push({ success: false, ...s.task, error: reason })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user