feat(video-from-script): 区分视频生成超时与任务失败,保留 taskId 支持恢复
核心改动:将超时与任务失败明确区分,超时场景保留 taskId 和 pending 状态, 等待下次恢复,避免重复计费;仅 API 明确返回 failed 时才清理 taskId 并重试。 - phase-videos.js: 恢复失败分超时/失败处理,超时保留 taskId 置 pending - phase-videos.js: 轮询结果超时也保留 taskId 存 pending - video-poll-utils.js: 增加 isTaskFailed 判断;超时禁止创建新任务 - video-poll-utils.js: 提升轮询重试次数至 5 次以允许超时等待
This commit is contained in:
@@ -44,7 +44,7 @@ async function phaseVideos(manifest, manifestPath, options) {
|
|||||||
console.log()
|
console.log()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 已有视频(本地或 OSS)且状态为 done 的跳过,其余清理后重新生成
|
// 已有视频(本地或 OSS)且状态为 done 的跳过,其余清理视频引用但保留 taskId 供恢复
|
||||||
const items = []
|
const items = []
|
||||||
for (const it of videoCandidates) {
|
for (const it of videoCandidates) {
|
||||||
if (it.video || it.videoUrl) {
|
if (it.video || it.videoUrl) {
|
||||||
@@ -52,7 +52,6 @@ async function phaseVideos(manifest, manifestPath, options) {
|
|||||||
delete it.video
|
delete it.video
|
||||||
delete it.videoUrl
|
delete it.videoUrl
|
||||||
delete it.videoDuration
|
delete it.videoDuration
|
||||||
delete it.videoTaskId
|
|
||||||
}
|
}
|
||||||
items.push(it)
|
items.push(it)
|
||||||
}
|
}
|
||||||
@@ -107,9 +106,15 @@ async function phaseVideos(manifest, manifestPath, options) {
|
|||||||
log('videos', ` item ${item.id} 恢复成功`)
|
log('videos', ` item ${item.id} 恢复成功`)
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log('videos', ` item ${item.id} 恢复失败: ${err.message},将重新提交`)
|
const isFail = err.isTaskFailure === true
|
||||||
delete item.videoTaskId
|
if (isFail) {
|
||||||
needSubmit.push(item)
|
log('videos', ` item ${item.id} 恢复失败(任务失败): ${err.message},将重新提交`)
|
||||||
|
delete item.videoTaskId
|
||||||
|
needSubmit.push(item)
|
||||||
|
} else {
|
||||||
|
log('videos', ` item ${item.id} 恢复超时(保留 taskId 下次重试): ${err.message}`)
|
||||||
|
item.status = 'pending'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
@@ -172,25 +177,32 @@ async function phaseVideos(manifest, manifestPath, options) {
|
|||||||
})
|
})
|
||||||
return { item, result, ok: true }
|
return { item, result, ok: true }
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return { item, error: err.message, ok: false }
|
return { item, error: err.message, ok: false, isTaskFailure: err.isTaskFailure === true }
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
for (const r of pollResults) {
|
for (const r of pollResults) {
|
||||||
const val = r.status === 'fulfilled' ? r.value : { ok: false, error: r.reason?.message }
|
const val = r.status === 'fulfilled'
|
||||||
|
? r.value
|
||||||
|
: { ok: false, error: r.reason?.message || String(r.reason), isTaskFailure: r.reason?.isTaskFailure === true }
|
||||||
if (val.ok && val.result.file) {
|
if (val.ok && val.result.file) {
|
||||||
val.item.video = path.relative(dir, val.result.file).replace(/\\/g, '/')
|
val.item.video = path.relative(dir, val.result.file).replace(/\\/g, '/')
|
||||||
val.item.videoDuration = val.result.duration
|
val.item.videoDuration = val.result.duration
|
||||||
val.item.status = 'done'
|
val.item.status = 'done'
|
||||||
delete val.item.videoTaskId
|
delete val.item.videoTaskId
|
||||||
} else if (val.item) {
|
} else if (val.item) {
|
||||||
val.item.status = 'failed'
|
if (val.isTaskFailure) {
|
||||||
val.item.error = val.error || '视频生成未返回文件'
|
val.item.status = 'failed'
|
||||||
delete val.item.videoTaskId
|
val.item.error = val.error || '视频生成未返回文件'
|
||||||
|
delete val.item.videoTaskId
|
||||||
|
} else {
|
||||||
|
log('videos', ` item ${val.item.id} 生成超时(保留 taskId 待恢复): ${val.error}`)
|
||||||
|
val.item.status = 'pending'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
saveManifest(manifestPath, manifest)
|
||||||
}
|
}
|
||||||
saveManifest(manifestPath, manifest)
|
|
||||||
|
|
||||||
// 上传视频到 OSS
|
// 上传视频到 OSS
|
||||||
const { uploadFile } = require('../oss-upload')
|
const { uploadFile } = require('../oss-upload')
|
||||||
|
|||||||
@@ -2,7 +2,9 @@
|
|||||||
* 共享视频轮询重试工具
|
* 共享视频轮询重试工具
|
||||||
*
|
*
|
||||||
* 提供 pollWithRetry 工厂函数,供 kling/veo/grok 三个视频生成器共用。
|
* 提供 pollWithRetry 工厂函数,供 kling/veo/grok 三个视频生成器共用。
|
||||||
* 两层重试:轮询级(同一 taskId,处理网络瞬断)→ 任务级(创建新 task + 优化提示词)
|
* 两层重试:轮询级(同一 taskId,处理网络瞬断/超时)→ 任务级(仅 API 明确返回 failed 才创建新 task)
|
||||||
|
*
|
||||||
|
* 铁律:超时 ≠ 失败。超时说明服务端还在跑,创建新任务会重复计费。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const path = require('path')
|
const path = require('path')
|
||||||
@@ -10,9 +12,11 @@ const fs = require('fs')
|
|||||||
const https = require('https')
|
const https = require('https')
|
||||||
const http = require('http')
|
const http = require('http')
|
||||||
|
|
||||||
const TRANSIENT_RE = /timeout|ECONNRESET|ETIMEDOUT|network|socket/i
|
const TRANSIENT_RE = /timeout|ECONNRESET|ETIMEDOUT|network|socket|超时|processing|pending/i
|
||||||
|
|
||||||
const POLL_RETRIES = 2 // 同一 task 轮询重试次数
|
const TASK_FAILED_RE = /\bfailed\b|失败/i
|
||||||
|
|
||||||
|
const POLL_RETRIES = 5 // 同一 task 轮询重试次数(含超时等待)
|
||||||
const POLL_RETRY_DELAY = 5000 // 轮询重试间隔 ms
|
const POLL_RETRY_DELAY = 5000 // 轮询重试间隔 ms
|
||||||
const TASK_RETRY_DELAY = 5000 // 任务级重试间隔 ms
|
const TASK_RETRY_DELAY = 5000 // 任务级重试间隔 ms
|
||||||
|
|
||||||
@@ -20,6 +24,10 @@ function isTransientError(err) {
|
|||||||
return TRANSIENT_RE.test(err.message || '')
|
return TRANSIENT_RE.test(err.message || '')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isTaskFailed(err) {
|
||||||
|
return TASK_FAILED_RE.test(err.message || '')
|
||||||
|
}
|
||||||
|
|
||||||
async function download(url, outputPath) {
|
async function download(url, outputPath) {
|
||||||
const protocol = url.startsWith('https') ? https : http
|
const protocol = url.startsWith('https') ? https : http
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
@@ -57,9 +65,16 @@ function makePollWithRetry({ Api, suffix, duration, maxRetries = 3, optimizeProm
|
|||||||
let currentTaskId = taskId
|
let currentTaskId = taskId
|
||||||
let currentPrompt = prompt
|
let currentPrompt = prompt
|
||||||
let lastError = null
|
let lastError = null
|
||||||
|
let lastErrorWasTaskFailure = false
|
||||||
|
|
||||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||||
if (attempt > 0) {
|
if (attempt > 0) {
|
||||||
|
// 只有 API 明确返回 failed 才创建新任务。超时/网络问题禁止创建新任务(原任务仍在服务端运行,重复计费)
|
||||||
|
if (!lastErrorWasTaskFailure) {
|
||||||
|
const err = new Error(`视频生成超时,放弃等待: ${lastError}`)
|
||||||
|
err.isTaskFailure = false
|
||||||
|
throw err
|
||||||
|
}
|
||||||
if (optimizePrompt) {
|
if (optimizePrompt) {
|
||||||
currentPrompt = optimizePrompt(prompt, lastError, attempt)
|
currentPrompt = optimizePrompt(prompt, lastError, attempt)
|
||||||
}
|
}
|
||||||
@@ -89,8 +104,10 @@ function makePollWithRetry({ Api, suffix, duration, maxRetries = 3, optimizeProm
|
|||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
lastError = err.message
|
lastError = err.message
|
||||||
|
lastErrorWasTaskFailure = isTaskFailed(err)
|
||||||
if (isTransientError(err) && pollAttempt < POLL_RETRIES) {
|
if (isTransientError(err) && pollAttempt < POLL_RETRIES) {
|
||||||
console.log(` ⚠ 轮询瞬断 (${pollAttempt + 1}/${POLL_RETRIES}): ${err.message.slice(0, 60)}`)
|
const tag = isTaskFailed(err) ? '失败' : '超时/瞬断'
|
||||||
|
console.log(` ⚠ 轮询${tag} (${pollAttempt + 1}/${POLL_RETRIES}): ${err.message.slice(0, 60)}`)
|
||||||
await new Promise(r => setTimeout(r, POLL_RETRY_DELAY))
|
await new Promise(r => setTimeout(r, POLL_RETRY_DELAY))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -98,13 +115,15 @@ function makePollWithRetry({ Api, suffix, duration, maxRetries = 3, optimizeProm
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (attempt < maxRetries) {
|
if (attempt < maxRetries && lastErrorWasTaskFailure) {
|
||||||
await new Promise(r => setTimeout(r, TASK_RETRY_DELAY))
|
await new Promise(r => setTimeout(r, TASK_RETRY_DELAY))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error(`重试 ${maxRetries} 次后仍失败: ${lastError}`)
|
const finalErr = new Error(`重试 ${maxRetries} 次后仍失败: ${lastError}`)
|
||||||
|
finalErr.isTaskFailure = true
|
||||||
|
throw finalErr
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { makePollWithRetry, POLL_RETRIES, POLL_RETRY_DELAY, TASK_RETRY_DELAY }
|
module.exports = { makePollWithRetry, isTaskFailed, POLL_RETRIES, POLL_RETRY_DELAY, TASK_RETRY_DELAY }
|
||||||
|
|||||||
Reference in New Issue
Block a user