diff --git a/.claude/skills/config.json b/.claude/skills/config.json index e386234..3bf1509 100644 --- a/.claude/skills/config.json +++ b/.claude/skills/config.json @@ -47,7 +47,7 @@ "id": "cosyvoice-v3-plus-bailian-155c1d86a5564d4ca981147d79e309b1", "model": "cosyvoice-v3-plus", "instruction": "用沉稳有力的男性声音朗读,语速适中,语气坚定有力,像是一个有经历有力量的人在平静地讲述生活的方向", - "style": "沉稳有力男声(v3-plus模型)" + "style": "沉稳有力男声" }, { "name": "六沉", diff --git a/.claude/skills/video-from-script/scripts/batch-pipeline.js b/.claude/skills/video-from-script/scripts/batch-pipeline.js index a41658f..082e405 100644 --- a/.claude/skills/video-from-script/scripts/batch-pipeline.js +++ b/.claude/skills/video-from-script/scripts/batch-pipeline.js @@ -20,6 +20,19 @@ const { SKILLS_DIR, ACCOUNTS_DIR, loadConfig, resolveVoice } = require('./lib/pi // output/ 在项目根的父级(美图/output/) const OUTPUT_BASE = path.join(SKILLS_DIR, '..', '..', '..', 'output') +// ============================================================================ +// 工具函数 +// ============================================================================ + +/** 从 batch item 解析草稿地址:自身 draftUrl 优先,否则读子任务 manifest */ +function resolveDraftUrl(item) { + if (item.draftUrl) return item.draftUrl + if (!item.manifestPath) return '' + try { + return readJson(path.resolve(item.manifestPath)).draftUrl || '' + } catch { return '' } +} + // ============================================================================ // CLI 参数解析 // ============================================================================ @@ -259,7 +272,11 @@ function cmdMark(args) { if (args.draftName) item.draftName = args.draftName if (args.forwardCopy) item.forwardCopy = args.forwardCopy if (args.hashtags) item.hashtags = args.hashtags - if (args.draftUrl) item.draftUrl = args.draftUrl + if (args.draftUrl) { + item.draftUrl = args.draftUrl + } else if (args.status === 'completed' && !item.draftUrl) { + item.draftUrl = resolveDraftUrl(item) || undefined + } batch.stats = calcStats(batch.items) writeJson(manifestPath, batch) @@ -304,6 +321,11 @@ function cmdNext(args) { return } + // 原子标记为 processing,防止同一行被重复取出 + item.status = 'processing' + batch.stats = calcStats(batch.items) + writeJson(manifestPath, batch) + const result = { done: false, row: item.row, @@ -359,19 +381,18 @@ function cmdExport(args) { } const forwardFull = [forwardBody, htags].filter(Boolean).join('') - // 选题列:topicA(方案A)> 旧字段 topic > 原 title - const topicDisplay = item.topicA || item.topic || item.title || '' rows.push({ row: item.row, - 选题: topicDisplay, + 选题一: item.topicA || item.topic || item.title || '', + 选题二: item.topicB || '', 脚本: script, 账号: item.account, 模式: item.mode, 音色: item.voice || '', 转发文案带话题: forwardFull, 草稿名称: item.draftName || '', - 草稿地址: item.draftUrl || '', + 草稿地址: resolveDraftUrl(item), }) } @@ -379,19 +400,26 @@ function cmdExport(args) { rows.sort((a, b) => a.row - b.row) const format = args.format || 'csv' - const dateStr = formatDate(new Date()) - const baseName = path.basename(manifestPath, '.json') + + // 生成文件名:账号名_MMDD_起号~止号 + const accountName = rows[0]?.账号 || batch.defaultAccount || 'unknown' + const dateMMDD = formatDate(new Date()).slice(4) // MMDD + const rowNums = rows.map(r => r.row) + const rowStart = String(Math.min(...rowNums)).padStart(2, '0') + const rowEnd = String(Math.max(...rowNums)).padStart(2, '0') + const exportName = `${accountName}_${dateMMDD}_${rowStart}~${rowEnd}` if (format === 'xlsx') { - exportXlsx(manifestPath, rows) + exportXlsx(manifestPath, rows, exportName) } else { - exportCsv(manifestPath, rows) + exportCsv(manifestPath, rows, exportName) } } -function exportCsv(manifestPath, rows) { - const outPath = manifestPath.replace('.json', '_export.csv') - const headers = ['选题', '脚本', '账号', '模式', '音色', '转发文案带话题', '草稿名称', '草稿地址'] +function exportCsv(manifestPath, rows, exportName) { + const batchDir = path.dirname(manifestPath) + const outPath = path.join(batchDir, `${exportName}.csv`) + const headers = ['选题一', '选题二', '脚本', '账号', '模式', '音色', '转发文案带话题', '草稿名称', '草稿地址'] const lines = [headers.join(',')] for (const r of rows) { @@ -415,10 +443,10 @@ function exportCsv(manifestPath, rows) { printTable(rows, headers) } -function exportXlsx(manifestPath, rows) { +function exportXlsx(manifestPath, rows, exportName) { try { const XLSX = require('xlsx') - const headers = ['选题', '脚本', '账号', '模式', '音色', '转发文案带话题', '草稿名称', '草稿地址'] + const headers = ['选题一', '选题二', '脚本', '账号', '模式', '音色', '转发文案带话题', '草稿名称', '草稿地址'] const data = rows.map(r => headers.map(h => r[h] || '')) data.unshift(headers) @@ -426,7 +454,8 @@ function exportXlsx(manifestPath, rows) { const wb = XLSX.utils.book_new() XLSX.utils.book_append_sheet(wb, ws, '视频清单') - const outPath = manifestPath.replace('.json', '_export.xlsx') + const batchDir = path.dirname(manifestPath) + const outPath = path.join(batchDir, `${exportName}.xlsx`) XLSX.writeFile(wb, outPath) console.log(`表格已导出: ${outPath}`) console.log(` 共 ${rows.length} 条记录`) @@ -436,7 +465,7 @@ function exportXlsx(manifestPath, rows) { } catch (err) { if (err.code === 'MODULE_NOT_FOUND') { console.warn('xlsx 模块未安装,改用 CSV 格式') - exportCsv(manifestPath, rows) + exportCsv(manifestPath, rows, exportName) } else { throw err } 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-assemble.js b/.claude/skills/video-from-script/scripts/lib/phase-assemble.js index 35726d4..1fc6151 100644 --- a/.claude/skills/video-from-script/scripts/lib/phase-assemble.js +++ b/.claude/skills/video-from-script/scripts/lib/phase-assemble.js @@ -46,12 +46,14 @@ async function phaseAssemble(manifest, manifestPath, options) { try { const { assemble } = require('../capcut_assemble') + const { BASE_URL } = require('./capcut-api') const result = await assemble(assembleArgs) // 保存草稿地址到 manifest,供批量导出使用 - if (result && result.draftUrl) { - manifest.draftUrl = result.draftUrl + // 用 BASE_URL + draft_id 构造公网可下载的绝对路径 + if (result && result.draftId) { + manifest.draftUrl = `${BASE_URL}/get_draft?draft_id=${result.draftId}` fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), 'utf-8') - log('assemble', `草稿地址已保存: ${result.draftUrl}`) + log('assemble', `草稿地址已保存: ${manifest.draftUrl}`) } log('assemble', '成片完成') } catch (err) { 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..e0f5f6c 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,13 +40,11 @@ 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 + // 已有视频(本地文件或远程 URL)且状态为 done → 跳过,避免重复生成 + if (it.status === 'done' && (it.video || it.videoUrl)) return false return ['done', 'pending', 'failed'].includes(it.status) }) @@ -34,7 +56,9 @@ async function phaseVideos(manifest, manifestPath, options) { if (it.confirmed === false) reasons.push("confirmed=false") if (!it.url) reasons.push("缺少 url(图片未上传)") if (!it.videoPrompt) reasons.push("缺少 videoPrompt") - if (it.confirmed !== false && it.url && it.videoPrompt && !["done","pending","failed"].includes(it.status)) { + if (it.status === 'done' && (it.video || it.videoUrl)) { + reasons.push("视频已生成,已跳过") + } else if (!["done","pending","failed"].includes(it.status)) { reasons.push("status=" + (it.status || "undefined") + "(不在 done/pending/failed 中)") } console.log(" - item", it.id || manifest.items.indexOf(it), ":", reasons.length > 0 ? reasons.join(", ") : "已满足全部条件(不应在此)") @@ -44,21 +68,32 @@ async function phaseVideos(manifest, manifestPath, options) { console.log() } - // 已有视频(本地或 OSS)且状态为 done 的跳过,其余清理后重新生成 const items = [] for (const it of videoCandidates) { + // 磁盘兜底:本地视频文件已存在则恢复引用并跳过 + if (!it.video && it.id) { + const fs = require('fs') + const existingVideos = fs.readdirSync(videosDir).filter(f => + f.includes('_item' + it.id + '_') || f.includes('_item' + it.id + '.') + ) + if (existingVideos.length > 0) { + it.video = 'videos/' + existingVideos[existingVideos.length - 1] + it.status = 'done' + delete it.videoTaskId + log('videos', ` item ${it.id} 发现已有视频文件 ${it.video},跳过生成`) + continue + } + } if (it.video || it.videoUrl) { if (it.status === 'done') continue delete it.video delete it.videoUrl delete it.videoDuration - delete it.videoTaskId } items.push(it) } if (items.length === 0) { log('videos', '无待处理 item,跳过'); return } - // 选择生成器 let Api, pollFn const modelLower = videoModel.toLowerCase() if (modelLower.includes('grok')) { @@ -73,6 +108,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) @@ -93,12 +133,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 @@ -107,9 +142,14 @@ 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) + if (err.isTaskFailure === true) { + 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' + } } }) ) @@ -118,14 +158,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' } @@ -138,60 +180,54 @@ 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 + } else if (val.item && !val.taskId) { + val.item.status = 'failed' + val.item.error = val.error || '提交失败' + if (isQuotaError(val.error)) hitQuota = true } } 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 || '提交失败' } + 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) - 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 } - } - }) - ) - - for (const r of pollResults) { - const val = r.status === 'fulfilled' ? r.value : { ok: false, error: r.reason?.message } - 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 - } - } - saveManifest(manifestPath, manifest) - // 上传视频到 OSS const { uploadFile } = require('../oss-upload') const videoItems = manifest.items.filter(it => it.video && !it.videoUrl) 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 } diff --git a/CLAUDE.md b/CLAUDE.md index 4759f18..9b4d00f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -131,11 +131,12 @@ node .claude/skills/video-from-script/scripts/batch-pipeline.js rename-drafts -- node .claude/skills/video-from-script/scripts/batch-pipeline.js export --file output/batch_XXX/batch-manifest.json ``` -输出 CSV 表格,列:`选题 | 脚本 | 账号 | 模式 | 音色 | 转发文案带话题 | 草稿名称` +输出 CSV 表格,列:`选题一 | 选题二 | 脚本 | 账号 | 模式 | 音色 | 转发文案带话题 | 草稿名称 | 草稿地址` -- **选题**列 = 方案A(封面双句) +- **选题一**列 = 方案A(封面双句) +- **选题二**列 = 方案B(封面短标题) - **草稿名称**列 = `账号名_月日_序号_方案B` -- CSV 文件路径:`batch-manifest_export.csv`(与 manifest 同目录) +- 导出文件命名:`账号名_MMDD_起号~止号.csv`,如 `执黑先行_0516_01~16.csv`(与 manifest 同目录) - 导出后询问用户是否打包草稿到桌面 ## 草稿箱改名 diff --git a/accounts/执黑先行/account.json b/accounts/执黑先行/account.json index 3178d44..5d0d7eb 100644 --- a/accounts/执黑先行/account.json +++ b/accounts/执黑先行/account.json @@ -12,9 +12,9 @@ "references": [] } }, - "ttsVoice": "斯内普", + "ttsVoice": "谢尔比", "ttsRate": 1.3, - "ttsInstruction": "用沉稳有力的男性声音朗读,语速适中,语气坚定有力,像是一个有经历有力量的人在平静地讲述生活的方向", + "ttsInstruction": "用一种过来人的口吻说话,像是经历了太多懒得废话的大哥,语气里带点漫不经心和不耐烦。声音不用太用力,轻描淡写但每个字都砸在点上。偶尔轻哼一口气,显得很松弛。", "storyboardPrompt": "prompts/分镜.md", "imageStylePrompt": "prompts/图片提示词.md", "videoStylePrompt": "prompts/视频提示词.md",