diff --git a/.claude/skills/config.json b/.claude/skills/config.json index f207093..a35f112 100644 --- a/.claude/skills/config.json +++ b/.claude/skills/config.json @@ -17,8 +17,9 @@ "veoEnhancePrompt": true, "veoEnableUpsample": true, "kelingApiBaseUrl": "https://api-beijing.klingai.com", - "kelingApiKey": "nreeg9bbKekdeenAma4KA3bkHMQkG4ND", - "kelingModel": "Kling-V2-5-Turbo", + "kelingApiKey": "AR4kMTEGaaM4d4QgJmLYMgHmEFABJPFE", + "kelingSecretAccessKey": "aBCrHLYTPPgMm3mnE8RBMAtmY9FLTGT3", + "kelingModel": "kling-v2-5-turbo", "ossRegion": "oss-cn-hangzhou", "ossAccessKeyId": "LTAI5tPV9Ag3csf41GZjaLTA", "ossAccessKeySecret": "kDqlGeJTKw6tJtFYiaY8vQTFuVIQDs", diff --git a/.claude/skills/video-from-script/references/manifest-schema.md b/.claude/skills/video-from-script/references/manifest-schema.md index 90f8559..70f7dbe 100644 --- a/.claude/skills/video-from-script/references/manifest-schema.md +++ b/.claude/skills/video-from-script/references/manifest-schema.md @@ -28,13 +28,12 @@ node pipeline.js validate --manifest |------|------|------|--------| | `account` | 账号 ID | account.json | **init 自动** | | `imageModel` | `gemini` / `mj` | account.json | **init 自动** | -| `videoModel` | `veo3-fast` / `grok-video-3` 等 | account.json | **init 自动** | +| `videoModel` | `veo3-fast-frames` / `grok-video-3` / `kling` 等 | account.json | **init 自动** | | `format` | 画幅:`9:16` / `16:9` | account.json | **init 自动** | | `mode` | `single` 单图 / `framePair` 首尾帧 | CLI 参数 | **init 自动** | | `references` | 参考图数组,从 account.json styles.*.references 搬入 | account.json | **init 自动** | | `items` | 素材数组(AI 提供创意内容) | CLI --items | **AI → init** | -**init 自动继承的字段不需要 AI 关心,不会出错。** --- @@ -82,6 +81,85 @@ node pipeline.js validate --manifest --- +## 状态机 + +### item 生命周期 + +``` +pending → [images] → done → [upload: url填入] → done → [videos] → done → [tts] → done + ↓ ↓ + failed failed + error +``` + +status 一旦进入 `done` 就不再回退。后续阶段通过检查"有前置字段 + 无后置字段"来识别待处理 item,不依赖 status 变化。 + +### 各阶段拾取条件 + +Agent **不需要记住这些条件**,pipeline 内部自动匹配。仅供理解原理: + +| 阶段 | item 被拾取的条件 | +|------|------------------| +| images | `status=pending` + 有 `imagePrompt` | +| upload | `status=done` + 有 `file` + 无 `url` | +| videos | `status=done` + 有 `url` + 有 `videoPrompt` + 无 `video` | +| tts | `status=done` + 有 `text` + 无 `audio` | + +### pipeline.phases 整体状态 + +每个阶段有独立状态:`pending` → `running` → `done` / `partial` / `failed` + +- `done` — 全部 item 成功 +- `partial` — 部分 item 失败(其他成功) +- `failed` — 阶段整体异常中断 + +--- + +## 失败处理 + +用 `--retry-failed` 一条命令搞定。 + +### 根据失败阶段选择操作 + +**图片生成失败**(images 阶段 partial): + +```bash +# 只改 prompt 不改图片风格 → 重试即可 +node pipeline.js run --manifest --phase images --retry-failed + +# 需要换 prompt → 先改 item.imagePrompt,再重试 +# (改完后跑上面同一条命令) +``` + +**视频生成失败**(videos 阶段 partial): + +```bash +# API 临时故障、网络超时 → 直接重试 +node pipeline.js run --manifest --phase videos --retry-failed + +# 提示词问题 → 先改 item.videoPrompt,再重试 +# (改完后跑上面同一条命令) + +# 视频模型不可用 → 改 manifest.videoModel 或 account.json,再重试 +``` + +**全阶段重试**: + +```bash +node pipeline.js run --manifest --retry-failed +``` + +### `--retry-failed` 内部行为 + +1. 扫描所有 `status=failed` 或 `status=partial` 的 item +2. 根据已有字段自动判断应重置到哪个阶段: + - 有 `url` + `videoPrompt` + 无 `video` → 重置为可生视频(`status=done`) + - 无 `url` + 有 `imagePrompt` → 重置为可生图(`status=pending`) +3. 对应 `pipeline.phases` 重置为 `pending` +4. 清除 `error` 字段 +5. 正常执行指定阶段 + +--- + ## 首尾帧模式 `mode: "framePair"` 时,`imagePrompt` 作为起始帧,每个 item 额外字段: 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 013cdbc..f4636ab 100644 --- a/.claude/skills/video-from-script/scripts/kling-video-generator.js +++ b/.claude/skills/video-from-script/scripts/kling-video-generator.js @@ -1,25 +1,25 @@ #!/usr/bin/env node /** - * Kling Video Generator - 图生视频工具(可灵模型) + * Kling Video Generator - 图生视频(官方可灵 API) * - * 功能: - * - 提交图生视频任务(Kling 模型) - * - 支持单图和首尾帧模式 - * - 轮询直到完成(60-300秒) - * - 失败自动优化提示词重试(最多3次) - * - 批量并行生成 + manifest.json 文案透传 + * 使用官方 Kling API (api-beijing.klingai.com) 进行图生视频 + * AK/SK → JWT (HMAC-SHA256) 鉴权 * - * 用法: - * node kling-video-generator.js --image ./ref.jpg --prompt "zoom in slowly" -o ./output - * node kling-video-generator.js --image ./first.jpg --last-frame ./last.jpg --prompt "transition" -o ./output + * 凭证来源(优先级): + * 1. ~/.config/kling/.credentials (klingai skill 存储) + * 2. config.json 的 kelingAccessKeyId + kelingSecretAccessKey + * (向下兼容 kelingApiKey 作为 AK) + * + * 用法: + * node kling-video-generator.js --image --prompt "zoom in" -o ./output + * node kling-video-generator.js --image --last-frame --prompt "过渡" -o ./output * node kling-video-generator.js batch ./manifest.json -o ./output */ const fs = require('fs') const path = require('path') -const https = require('https') -const http = require('http') +const crypto = require('crypto') // ============================================================================ // 配置 @@ -36,14 +36,112 @@ function loadConfig() { const cfg = loadConfig() const Config = { - baseUrl: cfg.kelingApiBaseUrl , - apiKey: cfg.kelingApiKey || '', - model: cfg.kelingModel || 'Kling-V2-5-Turbo', + apiBase: (cfg.kelingApiBaseUrl || 'https://api-beijing.klingai.com').replace(/\/+$/, ''), + model: cfg.kelingModel || 'kling-v3', pollInterval: 10000, maxPollTime: 600000, maxRetries: 3, } +// ============================================================================ +// JWT 鉴权(来自可灵官方 API 规范) +// ============================================================================ + +function base64url(buf) { + return Buffer.from(buf).toString('base64') + .replace(/=/g, '') + .replace(/\+/g, '-') + .replace(/\//g, '_') +} + +function makeJwt(accessKey, secretKey) { + const header = base64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' })) + const now = Math.floor(Date.now() / 1000) + const payload = base64url(JSON.stringify({ + iss: accessKey, + exp: now + 1800, + nbf: now - 5, + })) + const signature = base64url( + crypto.createHmac('sha256', secretKey).update(`${header}.${payload}`).digest() + ) + return `${header}.${payload}.${signature}` +} + +// ============================================================================ +// 凭证加载 +// ============================================================================ + +function getCredentialsDir() { + const home = process.env.HOME || process.env.USERPROFILE + return home ? path.join(home, '.config', 'kling') : null +} + +function parseCredentialsIni(content) { + let current = null + const profiles = {} + for (const line of content.split('\n')) { + const t = line.trim() + if (!t || t.startsWith('#') || t.startsWith(';')) continue + const m = t.match(/^\[([^\]]+)\]\s*$/) + if (m) { + current = m[1].trim() + if (!profiles[current]) profiles[current] = {} + continue + } + const eqIdx = t.indexOf('=') + if (eqIdx <= 0 || !current) continue + const k = t.slice(0, eqIdx).trim() + let v = t.slice(eqIdx + 1).trim() + if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) { + v = v.slice(1, -1) + } + profiles[current][k] = v + } + return profiles +} + +function readCredentialsFile() { + const dir = getCredentialsDir() + if (!dir) return { ak: '', sk: '' } + const credPath = path.join(dir, '.credentials') + if (!fs.existsSync(credPath)) return { ak: '', sk: '' } + try { + const profiles = parseCredentialsIni(fs.readFileSync(credPath, 'utf-8')) + const p = profiles['default'] || {} + return { + ak: (p.access_key_id || p.access_key || '').trim(), + sk: (p.secret_access_key || p.secret_key || '').trim(), + } + } catch { + return { ak: '', sk: '' } + } +} + +function loadCredentials() { + // 1. ~/.config/kling/.credentials (klingai skill) + const fileCreds = readCredentialsFile() + if (fileCreds.ak && fileCreds.sk) { + console.log(` 凭证来源: ~/.config/kling/.credentials`) + return { ak: fileCreds.ak, sk: fileCreds.sk } + } + + // 2. config.json + const ak = (cfg.kelingAccessKeyId || cfg.kelingApiKey || '').trim() + const sk = (cfg.kelingSecretAccessKey || '').trim() + if (ak && sk) { + console.log(` 凭证来源: config.json`) + return { ak, sk } + } + + throw new Error( + '未配置可灵 API 凭证(需要 AK + SK)。请选择以下方式之一:\n' + + ' 1. 在 config.json 中添加 kelingSecretAccessKey(kelingApiKey 作为 AK)\n' + + ' 2. 运行可灵绑定: node kling.mjs account --bind-url\n' + + ' 3. 在 ~/.config/kling/.credentials 中配置 AK/SK' + ) +} + // ============================================================================ // 提示词优化(失败时自动调整) // ============================================================================ @@ -82,31 +180,37 @@ function extractCoreSubject(prompt) { } // ============================================================================ -// API +// 官方可灵 API // ============================================================================ const KlingApi = { async create(imageUrl, prompt, options = {}) { const { - aspectRatio = '9:16', model = Config.model, + duration = 5, + mode = 'std', lastFrameUrl = '', } = options - const images = [] - if (imageUrl) images.push(imageUrl) - if (lastFrameUrl) images.push(lastFrameUrl) - - const mode = lastFrameUrl ? '首尾帧' : '单图' + const creds = loadCredentials() + const token = makeJwt(creds.ak, creds.sk) const body = { - model, + model_name: model, + image: imageUrl, prompt, - images, - aspect_ratio: aspectRatio, + duration: String(duration), + mode, } - console.log(`\n📡 提交 Kling 视频任务 [${mode}]`) + if (lastFrameUrl) { + body.image_tail = lastFrameUrl + } + + const modeLabel = lastFrameUrl ? '首尾帧' : '单图' + + console.log(`\n📡 提交可灵视频任务 [${modeLabel}]`) + console.log(` API: ${Config.apiBase}`) console.log(` 模型: ${model}`) console.log(` 提示词: ${prompt.substring(0, 80)}...`) if (lastFrameUrl) { @@ -115,79 +219,96 @@ const KlingApi = { } else { console.log(` 参考图: ${imageUrl ? imageUrl.substring(0, 60) + '...' : '无'}`) } - console.log(` 画幅: ${aspectRatio}`) + console.log(` 时长: ${duration}s | 画质: ${mode}`) - const res = await fetch(`${Config.baseUrl}/v1/video/create`, { + const res = await fetch(`${Config.apiBase}/v1/videos/image2video`, { method: 'POST', headers: { 'Content-Type': 'application/json', - 'Accept': 'application/json', - 'Authorization': `Bearer ${Config.apiKey}`, + 'Authorization': `Bearer ${token}`, }, body: JSON.stringify(body), }) - const result = await res.json() + const json = await res.json() - if (!result.id) { - throw new Error(`Kling 提交失败: ${JSON.stringify(result)}`) + if (json.code !== undefined && json.code !== 0 && json.code !== 200) { + throw new Error(`可灵 API 错误 (code=${json.code}): ${json.message || JSON.stringify(json)}`) } - console.log(` 任务 ID: ${result.id}`) - return result.id + const data = json.data || {} + const taskId = data.task_id + + if (!taskId) { + throw new Error(`可灵提交失败: ${JSON.stringify(json)}`) + } + + console.log(` 任务 ID: ${taskId}`) + return taskId }, async query(taskId) { - const res = await fetch(`${Config.baseUrl}/v1/video/query?id=${taskId}`, { + const creds = loadCredentials() + const token = makeJwt(creds.ak, creds.sk) + + const res = await fetch(`${Config.apiBase}/v1/videos/image2video/${taskId}`, { headers: { - 'Authorization': `Bearer ${Config.apiKey}`, - 'Accept': 'application/json', + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json', }, }) - return await res.json() + const json = await res.json() + + if (json.code !== undefined && json.code !== 0 && json.code !== 200) { + throw new Error(`可灵查询错误 (code=${json.code}): ${json.message}`) + } + + return json.data || {} }, async poll(taskId) { const startTime = Date.now() - let lastProgress = 0 + let lastStatus = '' - console.log(`\n⏳ 等待 Kling 视频生成(预计 60-300 秒)...`) + console.log(`\n⏳ 等待可灵视频生成(预计 60-300 秒)...`) while (Date.now() - startTime < Config.maxPollTime) { - const task = await KlingApi.query(taskId) + const data = await KlingApi.query(taskId) + const status = data.task_status || '' - if (task.status === 'completed') { + if (status === 'succeed') { + const videos = data.task_result?.videos || [] + if (videos.length === 0) throw new Error('可灵生成成功但未返回视频') console.log(`\n✅ 视频生成完成!`) - console.log(` 视频: ${task.video_url}`) - return { - success: true, - videoUrl: task.video_url, - } + console.log(` 视频: ${videos[0].url}`) + return { success: true, videoUrl: videos[0].url } } - if (task.status === 'failed') { - throw new Error(task.error || task.message || 'Kling 生成失败') + if (status === 'failed') { + throw new Error(data.task_status_msg || '可灵生成失败') } - const progress = task.progress || 0 - if (progress !== lastProgress) { - lastProgress = progress + if (status !== lastStatus) { + lastStatus = status const elapsed = Math.round((Date.now() - startTime) / 1000) - process.stdout.write(` 进度: ${progress}% 已等待: ${elapsed}s 状态: ${task.status}\r`) + console.log(` 状态: ${status} 已等待: ${elapsed}s`) } await new Promise(r => setTimeout(r, Config.pollInterval)) } - throw new Error(`Kling 生成超时 (${Config.maxPollTime / 1000}s)`) + throw new Error(`可灵生成超时 (${Config.maxPollTime / 1000}s)`) }, } // ============================================================================ -// 图片下载工具 +// 图片下载 // ============================================================================ +const https = require('https') +const http = require('http') + async function download(url, outputPath) { const protocol = url.startsWith('https') ? https : http return new Promise((resolve, reject) => { @@ -213,9 +334,8 @@ async function download(url, outputPath) { // ============================================================================ async function generate(imageUrl, prompt, options = {}) { - const { outputDir = './output', aspectRatio = '9:16' } = options + const { outputDir = './output', duration = 5, mode = 'std' } = options - if (!Config.apiKey) throw new Error('未配置 kelingApiKey,请在 config.json 中添加') fs.mkdirSync(outputDir, { recursive: true }) let currentPrompt = prompt @@ -229,7 +349,10 @@ async function generate(imageUrl, prompt, options = {}) { console.log(` 新提示词: ${currentPrompt}`) } - const taskId = await KlingApi.create(imageUrl, currentPrompt, { aspectRatio, lastFrameUrl: options.lastFrameUrl }) + const taskId = await KlingApi.create(imageUrl, currentPrompt, { + duration, mode, + lastFrameUrl: options.lastFrameUrl, + }) const result = await KlingApi.poll(taskId) const timestamp = new Date().toISOString().replace(/[:.]/g, '-') @@ -257,26 +380,20 @@ async function generate(imageUrl, prompt, options = {}) { } } - throw new Error(`Kling 视频生成失败(已重试 ${Config.maxRetries} 次): ${lastError}`) + throw new Error(`可灵视频生成失败(已重试 ${Config.maxRetries} 次): ${lastError}`) } // ============================================================================ -// 批量并行生成(支持 manifest.json 输入输出) +// 批量并行生成(支持 manifest.json) // ============================================================================ async function batchGenerate(tasks, options = {}) { - const { outputDir = './output' } = options - let aspectRatio = options.aspectRatio || '9:16' - const concurrency = options.concurrency || 2 + const { outputDir = './output', concurrency = 2, duration = 5, mode = 'std' } = options - if (!Config.apiKey) throw new Error('未配置 kelingApiKey,请在 config.json 中添加') fs.mkdirSync(outputDir, { recursive: true }) // 支持 manifest 格式 if (tasks.items && Array.isArray(tasks.items)) { - if (tasks.format || tasks.defaultFormat) { - aspectRatio = tasks.format || tasks.defaultFormat || aspectRatio - } tasks = tasks.items.map(item => ({ image: item.url || item.image || '', prompt: item.videoPrompt || item.prompt || 'cinematic motion', @@ -289,8 +406,8 @@ async function batchGenerate(tasks, options = {}) { } // Phase 1: 并行提交 - const mode = tasks.some(t => t.lastFrameUrl) ? '首尾帧' : '单图' - console.log(`\n📡 并行提交 ${tasks.length} 个 Kling 视频任务(并发: ${concurrency},模式: ${mode})...`) + const modeLabel = tasks.some(t => t.lastFrameUrl) ? '首尾帧' : '单图' + console.log(`\n📡 并行提交 ${tasks.length} 个可灵视频任务(并发: ${concurrency},模式: ${modeLabel})...`) const submitted = [] for (let i = 0; i < tasks.length; i += concurrency) { @@ -301,7 +418,9 @@ async function batchGenerate(tasks, options = {}) { const prompt = task.videoPrompt || task.prompt console.log(` [${idx + 1}/${tasks.length}] 提交: ${prompt.substring(0, 50)}...`) try { - const taskId = await KlingApi.create(task.image, prompt, { aspectRatio, lastFrameUrl: task.lastFrameUrl }) + const taskId = await KlingApi.create(task.image, prompt, { + duration, mode, lastFrameUrl: task.lastFrameUrl, + }) return { idx, taskId, task, error: null } } catch (err) { console.error(` [${idx + 1}] 提交失败: ${err.message}`) @@ -328,7 +447,10 @@ async function batchGenerate(tasks, options = {}) { const pollResults = await Promise.allSettled( pendingTasks.map(async ({ idx, taskId, task }) => { const prompt = task.videoPrompt || task.prompt - const result = await pollWithRetry(taskId, prompt, { outputDir, aspectRatio, imageUrl: task.image, lastFrameUrl: task.lastFrameUrl }) + const result = await pollWithRetry(taskId, prompt, { + outputDir, duration, mode, + imageUrl: task.image, lastFrameUrl: task.lastFrameUrl, + }) return { idx, ...result, task } }) ) @@ -374,7 +496,7 @@ async function batchGenerate(tasks, options = {}) { if (manifestItems.length > 0 && !options.skipManifestWrite) { const manifestPath = path.join(outputDir, 'manifest.json') fs.writeFileSync(manifestPath, JSON.stringify({ items: manifestItems }, null, 2)) - console.log(` 已生成 manifest.json(${manifestItems.length} 条,文案与视频对应)`) + console.log(` 已生成 manifest.json(${manifestItems.length} 条)`) } return results @@ -396,7 +518,7 @@ async function pollWithRetry(taskId, prompt, options = {}) { currentTaskId = await KlingApi.create( options.imageUrl || '', currentPrompt, - { aspectRatio: options.aspectRatio, lastFrameUrl: options.lastFrameUrl || '' } + { duration: options.duration, mode: options.mode, lastFrameUrl: options.lastFrameUrl || '' } ) } @@ -432,7 +554,7 @@ async function pollWithRetry(taskId, prompt, options = {}) { function showHelp() { console.log(` -🎬 Kling Video Generator - 图生视频工具(可灵模型) +🎬 Kling Video Generator - 图生视频(官方可灵 API) 用法: node kling-video-generator.js --image --prompt "指令" [options] @@ -441,25 +563,21 @@ function showHelp() { 选项: -o, --output 输出目录 (默认: ./output) - -a, --ar 宽高比 (默认: 9:16) + --duration 视频时长 5 或 10 (默认: 5) + --mode 画质 std/pro (默认: std) --model 模型名称 (默认: ${Config.model}) --last-frame 结束帧 URL(首尾帧模式) --retries 失败重试次数 (默认: 3) -h, --help 帮助 -模式: - 单图模式: --image --prompt "运动描述" - 首尾帧模式: --image <首帧url> --last-frame <尾帧url> --prompt "过渡描述" +凭证: + 需要在 config.json 中配置: + kelingApiBaseUrl: "https://api-beijing.klingai.com" + kelingApiKey: "<你的 Access Key ID>" + kelingSecretAccessKey: "<你的 Secret Access Key>" + kelingModel: "kling-v3" -示例: - # 单图 - node kling-video-generator.js --image http://img.com/ref.jpg --prompt "zoom in" -a 16:9 - - # 首尾帧 - node kling-video-generator.js --image http://img.com/first.jpg --last-frame http://img.com/last.jpg --prompt "过渡" -a 16:9 - - # 批量(自动检测单图/首尾帧) - node kling-video-generator.js batch ./manifest.json -o ./videos + 或使用 klingai skill 的 ~/.config/kling/.credentials `) } @@ -472,13 +590,13 @@ async function main() { } let command = 'single' - let params = [] const options = { outputDir: './output', - aspectRatio: '9:16', imageUrl: '', lastFrameUrl: '', prompt: '', + duration: 5, + mode: 'std', } let i = 0 @@ -487,12 +605,16 @@ async function main() { i = 1 } + const params = [] + while (i < args.length) { const arg = args[i] if (arg === '-o' || arg === '--output') { options.outputDir = args[++i] - } else if (arg === '-a' || arg === '--ar') { - options.aspectRatio = args[++i] + } else if (arg === '--duration') { + options.duration = parseInt(args[++i], 10) + } else if (arg === '--mode') { + options.mode = args[++i] } else if (arg === '--model') { Config.model = args[++i] } else if (arg === '--image') { diff --git a/.claude/skills/video-from-script/scripts/pipeline.js b/.claude/skills/video-from-script/scripts/pipeline.js index 0efcdf5..911e7bb 100644 --- a/.claude/skills/video-from-script/scripts/pipeline.js +++ b/.claude/skills/video-from-script/scripts/pipeline.js @@ -531,6 +531,46 @@ async function runPipeline(manifestPath, options) { manifest.pipeline = { phases: {} } } + // --retry-failed: 重置失败 item 状态,允许重新处理 + if (options.retryFailed) { + let resetCount = 0 + for (const item of manifest.items) { + if (item.status === 'failed' || item.status === 'partial') { + // 根据 item 是否有 url 判断该重置到哪个阶段 + if (item.url && item.videoPrompt && !item.video) { + // 图片已生成、有视频提示词、但没视频 → 重置为可生视频 + item.status = 'done' + item.error = '' + resetCount++ + } else if (!item.url && item.imagePrompt) { + // 没有图片但有提示词 → 重置为可生图 + item.status = 'pending' + item.error = '' + resetCount++ + } + } + } + // 重置对应阶段状态 + if (phases.includes('videos')) { + const hasVideoItems = manifest.items.some(it => it.status === 'done' && it.url && it.videoPrompt && !it.video) + if (hasVideoItems) manifest.pipeline.phases.videos = 'pending' + } + if (phases.includes('images')) { + const hasImageItems = manifest.items.some(it => !it.status || it.status === 'pending') + if (hasImageItems) manifest.pipeline.phases.images = 'pending' + } + if (phases.includes('upload')) { + manifest.pipeline.phases.upload = 'pending' + } + if (phases.includes('tts')) { + manifest.pipeline.phases.tts = 'pending' + } + if (resetCount > 0) { + log('pipeline', `重置 ${resetCount} 个失败 item (--retry-failed)`) + saveManifest(manifestPath, manifest) + } + } + log('pipeline', `阶段: ${phases.join(' → ')}`) const phaseHandlers = { @@ -1007,6 +1047,7 @@ function parseArgs(argv) { else if (argv[i] === '--account' && argv[i + 1]) args.account = argv[++i] else if (argv[i] === '--phase' && argv[i + 1]) args.phases = argv[++i].split(',') else if (argv[i] === '--resume') args.resume = true + else if (argv[i] === '--retry-failed') args.retryFailed = true else if (argv[i] === '--mode' && argv[i + 1]) args.mode = argv[++i] else if (argv[i] === '--items' && argv[i + 1]) args.items = argv[++i] else if (argv[i] === '--items-file' && argv[i + 1]) args.itemsFile = argv[++i] @@ -1045,7 +1086,7 @@ async function main() { } if (command === 'run') { - if (!args.manifest) { console.error('用法: pipeline.js run --manifest [--account id] [--phase p1,p2] [--resume]'); process.exit(1) } + if (!args.manifest) { console.error('用法: pipeline.js run --manifest [--account id] [--phase p1,p2] [--resume] [--retry-failed]'); process.exit(1) } await runPipeline(args.manifest, args) return } @@ -1066,7 +1107,7 @@ async function main() { console.log(' pipeline.js validate-account --account ') console.log(' pipeline.js init --account --mode --items [--items-file ]') console.log(' pipeline.js validate --manifest ') - console.log(' pipeline.js run --manifest [--account id] [--phase p1,p2] [--resume]') + console.log(' pipeline.js run --manifest [--account id] [--phase p1,p2] [--resume] [--retry-failed]') console.log(' pipeline.js status --manifest ') console.log('') console.log('Manifest 路径约定: output/{account}_{date}_{NNN}/manifest.json(同天自增序号)')