From 4495ea8af16c30b80dba7134acaaf3cbd6c8311c Mon Sep 17 00:00:00 2001 From: sion123 <450702724@qq.com> Date: Sat, 16 May 2026 22:44:09 +0800 Subject: [PATCH] =?UTF-8?q?feat(video-from-script):=20=E4=B8=A5=E6=A0=BC?= =?UTF-8?q?=E5=B9=B6=E8=A1=8C=E6=8F=90=E4=BA=A4=E6=89=B9=E6=AC=A1=E9=99=90?= =?UTF-8?q?=E5=88=B6=E5=B9=B6=E5=A2=9E=E5=BC=BA=E9=A2=9D=E5=BA=A6=E9=94=99?= =?UTF-8?q?=E8=AF=AF=E6=A3=80=E6=B5=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 引入 `isQuotaError` 函数,统一检测 API 额度不足错误 - Kling 生成器改为严格分批提交并等待每批轮询完成,限制并发 ≤ 5 - phase-videos 重构为分批提交+轮询模式,支持额度不足时提前终止 - 提取 `applyPollResult` 和 `pollOpts` 工具函数,减少代码重复 - 新增批量提交的进度日志,显示当前批次和总数 --- .../scripts/kling-video-generator.js | 111 ++++++++------ .../scripts/lib/phase-videos.js | 144 +++++++++--------- 2 files changed, 138 insertions(+), 117 deletions(-) diff --git a/.claude/skills/video-from-script/scripts/kling-video-generator.js b/.claude/skills/video-from-script/scripts/kling-video-generator.js index d90fffe..b6de80a 100644 --- a/.claude/skills/video-from-script/scripts/kling-video-generator.js +++ b/.claude/skills/video-from-script/scripts/kling-video-generator.js @@ -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 }) + } } } 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 9cd7e6c..8222520 100644 --- a/.claude/skills/video-from-script/scripts/lib/phase-videos.js +++ b/.claude/skills/video-from-script/scripts/lib/phase-videos.js @@ -8,6 +8,30 @@ const path = require('path') const { saveManifest, ensureDir, log, getManifestDir } = require('./pipeline-utils') +function isQuotaError(msg) { + if (!msg) return false + const s = msg.toLowerCase() + return /quota|limit|exceed|insufficient|余额|额度|超限|rate.?limit|too.?many/.test(s) +} + +function applyPollResult(item, val, dir) { + if (val.ok && val.result?.file) { + item.video = path.relative(dir, val.result.file).replace(/\\/g, '/') + item.videoDuration = val.result.duration + item.status = 'done' + delete item.videoTaskId + } else if (val.item) { + if (val.isTaskFailure) { + item.status = 'failed' + item.error = val.error || '视频生成未返回文件' + delete item.videoTaskId + } else { + log('videos', ` item ${item.id} 生成超时(保留 taskId 待恢复): ${val.error}`) + item.status = 'pending' + } + } +} + async function phaseVideos(manifest, manifestPath, options) { const dir = getManifestDir(manifestPath) const videosDir = path.join(dir, 'videos') @@ -16,10 +40,6 @@ async function phaseVideos(manifest, manifestPath, options) { const accountConfig = options.accountConfig || {} const videoModel = manifest.videoModel || accountConfig.videoModel || 'veo3-fast-frames' - // 筛选需要生视频的 item: - // done — 正常流程,图片已确认且已上传 - // pending / failed — 重试场景,agent 只需将 item 设为 pending 即可触发再生 - // 前提:有 url(图片已上传)+ videoPrompt,且 confirmed 未被显式拒绝 const videoCandidates = manifest.items.filter(it => { if (it.confirmed === false) return false if (!it.url || !it.videoPrompt) return false @@ -44,7 +64,6 @@ async function phaseVideos(manifest, manifestPath, options) { console.log() } - // 已有视频(本地或 OSS)且状态为 done 的跳过,其余清理视频引用但保留 taskId 供恢复 const items = [] for (const it of videoCandidates) { if (it.video || it.videoUrl) { @@ -57,7 +76,6 @@ async function phaseVideos(manifest, manifestPath, options) { } if (items.length === 0) { log('videos', '无待处理 item,跳过'); return } - // 选择生成器 let Api, pollFn const modelLower = videoModel.toLowerCase() if (modelLower.includes('grok')) { @@ -72,6 +90,11 @@ async function phaseVideos(manifest, manifestPath, options) { } const ratio = manifest.format || '9:16' + const pollOpts = (item) => ({ + outputDir: videosDir, aspectRatio: ratio, + imageUrl: item.url, lastFrameUrl: item.lastFrameUrl || '', + }) + log('videos', `共 ${items.length} 个, 模型: ${videoModel}`) // Phase 1: 恢复已有任务(有 videoTaskId 的 item) @@ -92,12 +115,7 @@ async function phaseVideos(manifest, manifestPath, options) { recovered.map(async (item) => { try { log('videos', ` 恢复 item ${item.id}: ${item.videoTaskId}`) - const result = await pollFn(item.videoTaskId, item.videoPrompt, { - outputDir: videosDir, - aspectRatio: ratio, - imageUrl: item.url, - lastFrameUrl: item.lastFrameUrl || '', - }) + const result = await pollFn(item.videoTaskId, item.videoPrompt, pollOpts(item)) if (result.file) { item.video = path.relative(dir, result.file).replace(/\\/g, '/') item.videoDuration = result.duration @@ -106,8 +124,7 @@ async function phaseVideos(manifest, manifestPath, options) { log('videos', ` item ${item.id} 恢复成功`) } } catch (err) { - const isFail = err.isTaskFailure === true - if (isFail) { + if (err.isTaskFailure === true) { log('videos', ` item ${item.id} 恢复失败(任务失败): ${err.message},将重新提交`) delete item.videoTaskId needSubmit.push(item) @@ -123,14 +140,16 @@ async function phaseVideos(manifest, manifestPath, options) { if (needSubmit.length === 0) { log('videos', '全部通过恢复完成'); return } - // Phase 2: 提交新任务(并发 5,Kling 最大并发) + // Phase 2+3: 分批提交+轮询(严格并发 ≤ 5,等一批完成再提交下一批) const concurrency = 5 log('videos', `提交 ${needSubmit.length} 个新任务(并发: ${concurrency})...`) - const submitted = [] for (let i = 0; i < needSubmit.length; i += concurrency) { - const batch = needSubmit.slice(i, i + concurrency) - const batchResults = await Promise.allSettled( + const batch = needSubmit.slice(i, i + concurrency).filter(item => !item.videoTaskId) + if (batch.length === 0) continue + + // 提交本批 + const submitResults = await Promise.allSettled( batch.map(async (item) => { const extraOpts = item.lastFrameUrl ? { aspectRatio: ratio, lastFrameUrl: item.lastFrameUrl, mode: 'pro' } @@ -143,65 +162,52 @@ async function phaseVideos(manifest, manifestPath, options) { } }) ) - for (const r of batchResults) { + + const submitted = [] + let hitQuota = false + for (const r of submitResults) { const val = r.status === 'fulfilled' ? r.value : { item: null, taskId: null, error: r.reason } submitted.push(val) if (val.item && val.taskId) { val.item.videoTaskId = val.taskId - } - } - saveManifest(manifestPath, manifest) - } - - // Phase 3: 轮询新任务 - const pending = submitted.filter(s => s.taskId) - if (pending.length === 0) { - log('videos', '所有任务提交失败') - for (const s of submitted) { - if (s.item) { s.item.status = 'failed'; s.item.error = s.error || '提交失败' } - } - saveManifest(manifestPath, manifest) - return - } - - log('videos', `等待 ${pending.length} 个视频生成...`) - - const pollResults = await Promise.allSettled( - pending.map(async ({ item, taskId }) => { - try { - const result = await pollFn(taskId, item.videoPrompt, { - outputDir: videosDir, - aspectRatio: ratio, - imageUrl: item.url, - lastFrameUrl: item.lastFrameUrl || '', - }) - return { item, result, ok: true } - } catch (err) { - 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 || 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) { - if (val.isTaskFailure) { + } else if (val.item && !val.taskId) { 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' + val.item.error = val.error || '提交失败' + if (isQuotaError(val.error)) hitQuota = true } } saveManifest(manifestPath, manifest) + + if (hitQuota) { + log('videos', ` ⚠️ 额度不足,停止提交(跳过剩余 ${needSubmit.length - i - batch.length} 个任务)`) + break + } + + // 轮询本批 + const pending = submitted.filter(s => s.taskId) + if (pending.length === 0) continue + + const end = Math.min(i + concurrency, needSubmit.length) + log('videos', ` [${i + 1}-${end}/${needSubmit.length}] 等待 ${pending.length} 个视频生成...`) + + const pollResults = await Promise.allSettled( + pending.map(async ({ item, taskId }) => { + try { + const result = await pollFn(taskId, item.videoPrompt, pollOpts(item)) + return { item, result, ok: true } + } catch (err) { + 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 || String(r.reason), isTaskFailure: r.reason?.isTaskFailure === true } + applyPollResult(val.item || {}, val, dir) + } + saveManifest(manifestPath, manifest) } // 上传视频到 OSS