/** * 共享视频轮询重试工具 * * 提供 pollWithRetry 工厂函数,供 kling/veo/grok 三个视频生成器共用。 * 两层重试:轮询级(同一 taskId,处理网络瞬断)→ 任务级(创建新 task + 优化提示词) */ const path = require('path') const fs = require('fs') const https = require('https') const http = require('http') const TRANSIENT_RE = /timeout|ECONNRESET|ETIMEDOUT|network|socket/i const POLL_RETRIES = 2 // 同一 task 轮询重试次数 const POLL_RETRY_DELAY = 5000 // 轮询重试间隔 ms const TASK_RETRY_DELAY = 5000 // 任务级重试间隔 ms function isTransientError(err) { return TRANSIENT_RE.test(err.message || '') } async function download(url, outputPath) { const protocol = url.startsWith('https') ? https : http return new Promise((resolve, reject) => { const file = fs.createWriteStream(outputPath) protocol.get(url, (response) => { if (response.statusCode >= 300 && response.statusCode < 400 && response.headers.location) { file.close() fs.unlinkSync(outputPath) return download(response.headers.location, outputPath).then(resolve).catch(reject) } response.pipe(file) file.on('finish', () => { file.close(); resolve(outputPath) }) }).on('error', (err) => { file.close() if (fs.existsSync(outputPath)) fs.unlinkSync(outputPath) reject(err) }) }) } /** * 创建 pollWithRetry 函数 * * @param {object} opts * @param {object} opts.Api - 有 create() 和 poll() 方法的 API 对象 * @param {string} opts.suffix - 输出文件后缀(如 '_kling') * @param {number} opts.duration - 视频时长(秒) * @param {number} [opts.maxRetries=3] - 任务级最大重试次数 * @param {function} [opts.optimizePrompt] - 提示词优化函数 (prompt, failReason, attempt) => optimizedPrompt * @param {function} opts.buildCreateOpts - (item_options) => create() 的第三个参数 * @returns {function} pollWithRetry(taskId, prompt, options) */ function makePollWithRetry({ Api, suffix, duration, maxRetries = 3, optimizePrompt, buildCreateOpts }) { return async function pollWithRetry(taskId, prompt, options = {}) { let currentTaskId = taskId let currentPrompt = prompt let lastError = null for (let attempt = 0; attempt <= maxRetries; attempt++) { if (attempt > 0) { if (optimizePrompt) { currentPrompt = optimizePrompt(prompt, lastError, attempt) } console.log(`\n 🔄 重试 (任务 ${currentTaskId.substring(0, 8)}...): ${currentPrompt.substring(0, 50)}`) const createOpts = buildCreateOpts(options) currentTaskId = await Api.create(options.imageUrl || '', currentPrompt, createOpts) } const outputDir = options.outputDir || './output' for (let pollAttempt = 0; pollAttempt <= POLL_RETRIES; pollAttempt++) { try { const result = await Api.poll(currentTaskId) const timestamp = new Date().toISOString().replace(/[:.]/g, '-') const videoFile = path.join(outputDir, `${timestamp}${suffix}.mp4`) await download(result.videoUrl, videoFile) return { taskId: currentTaskId, prompt: currentPrompt, originalPrompt: prompt, attempts: attempt + 1, file: videoFile, files: [videoFile], duration, } } catch (err) { lastError = err.message if (isTransientError(err) && pollAttempt < POLL_RETRIES) { console.log(` ⚠ 轮询瞬断 (${pollAttempt + 1}/${POLL_RETRIES}): ${err.message.slice(0, 60)}`) await new Promise(r => setTimeout(r, POLL_RETRY_DELAY)) continue } break } } if (attempt < maxRetries) { await new Promise(r => setTimeout(r, TASK_RETRY_DELAY)) } } throw new Error(`重试 ${maxRetries} 次后仍失败: ${lastError}`) } } module.exports = { makePollWithRetry, POLL_RETRIES, POLL_RETRY_DELAY, TASK_RETRY_DELAY }