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:
2026-05-16 19:22:33 +08:00
parent 06a4af7c1e
commit 83668cea3c
2 changed files with 49 additions and 18 deletions

View File

@@ -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')

View File

@@ -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 }