From 83668cea3c70afc97ab19f96c16d06ea45fde9ec Mon Sep 17 00:00:00 2001 From: sion123 <450702724@qq.com> Date: Sat, 16 May 2026 19:22:33 +0800 Subject: [PATCH] =?UTF-8?q?feat(video-from-script):=20=E5=8C=BA=E5=88=86?= =?UTF-8?q?=E8=A7=86=E9=A2=91=E7=94=9F=E6=88=90=E8=B6=85=E6=97=B6=E4=B8=8E?= =?UTF-8?q?=E4=BB=BB=E5=8A=A1=E5=A4=B1=E8=B4=A5=EF=BC=8C=E4=BF=9D=E7=95=99?= =?UTF-8?q?=20taskId=20=E6=94=AF=E6=8C=81=E6=81=A2=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 核心改动:将超时与任务失败明确区分,超时场景保留 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 次以允许超时等待 --- .../scripts/lib/phase-videos.js | 34 +++++++++++++------ .../scripts/lib/video-poll-utils.js | 33 ++++++++++++++---- 2 files changed, 49 insertions(+), 18 deletions(-) diff --git a/.claude/skills/video-from-script/scripts/lib/phase-videos.js b/.claude/skills/video-from-script/scripts/lib/phase-videos.js index 06ba39a..9cd7e6c 100644 --- a/.claude/skills/video-from-script/scripts/lib/phase-videos.js +++ b/.claude/skills/video-from-script/scripts/lib/phase-videos.js @@ -44,7 +44,7 @@ async function phaseVideos(manifest, manifestPath, options) { console.log() } - // 已有视频(本地或 OSS)且状态为 done 的跳过,其余清理后重新生成 + // 已有视频(本地或 OSS)且状态为 done 的跳过,其余清理视频引用但保留 taskId 供恢复 const items = [] for (const it of videoCandidates) { if (it.video || it.videoUrl) { @@ -52,7 +52,6 @@ async function phaseVideos(manifest, manifestPath, options) { delete it.video delete it.videoUrl delete it.videoDuration - delete it.videoTaskId } items.push(it) } @@ -107,9 +106,15 @@ async function phaseVideos(manifest, manifestPath, options) { log('videos', ` item ${item.id} 恢复成功`) } } catch (err) { - log('videos', ` item ${item.id} 恢复失败: ${err.message},将重新提交`) - delete item.videoTaskId - needSubmit.push(item) + const isFail = err.isTaskFailure === true + if (isFail) { + 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 } } 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) { - 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) { val.item.video = path.relative(dir, val.result.file).replace(/\\/g, '/') val.item.videoDuration = val.result.duration val.item.status = 'done' delete val.item.videoTaskId } else if (val.item) { - val.item.status = 'failed' - val.item.error = val.error || '视频生成未返回文件' - delete val.item.videoTaskId + if (val.isTaskFailure) { + val.item.status = 'failed' + 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 const { uploadFile } = require('../oss-upload') diff --git a/.claude/skills/video-from-script/scripts/lib/video-poll-utils.js b/.claude/skills/video-from-script/scripts/lib/video-poll-utils.js index b2815a1..9b78186 100644 --- a/.claude/skills/video-from-script/scripts/lib/video-poll-utils.js +++ b/.claude/skills/video-from-script/scripts/lib/video-poll-utils.js @@ -2,7 +2,9 @@ * 共享视频轮询重试工具 * * 提供 pollWithRetry 工厂函数,供 kling/veo/grok 三个视频生成器共用。 - * 两层重试:轮询级(同一 taskId,处理网络瞬断)→ 任务级(创建新 task + 优化提示词) + * 两层重试:轮询级(同一 taskId,处理网络瞬断/超时)→ 任务级(仅 API 明确返回 failed 才创建新 task) + * + * 铁律:超时 ≠ 失败。超时说明服务端还在跑,创建新任务会重复计费。 */ const path = require('path') @@ -10,9 +12,11 @@ const fs = require('fs') const https = require('https') 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 TASK_RETRY_DELAY = 5000 // 任务级重试间隔 ms @@ -20,6 +24,10 @@ function isTransientError(err) { return TRANSIENT_RE.test(err.message || '') } +function isTaskFailed(err) { + return TASK_FAILED_RE.test(err.message || '') +} + async function download(url, outputPath) { const protocol = url.startsWith('https') ? https : http return new Promise((resolve, reject) => { @@ -57,9 +65,16 @@ function makePollWithRetry({ Api, suffix, duration, maxRetries = 3, optimizeProm let currentTaskId = taskId let currentPrompt = prompt let lastError = null + let lastErrorWasTaskFailure = false for (let attempt = 0; attempt <= maxRetries; attempt++) { if (attempt > 0) { + // 只有 API 明确返回 failed 才创建新任务。超时/网络问题禁止创建新任务(原任务仍在服务端运行,重复计费) + if (!lastErrorWasTaskFailure) { + const err = new Error(`视频生成超时,放弃等待: ${lastError}`) + err.isTaskFailure = false + throw err + } if (optimizePrompt) { currentPrompt = optimizePrompt(prompt, lastError, attempt) } @@ -89,8 +104,10 @@ function makePollWithRetry({ Api, suffix, duration, maxRetries = 3, optimizeProm } } catch (err) { lastError = err.message + lastErrorWasTaskFailure = isTaskFailed(err) 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)) 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)) } } - 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 }