feat(video-from-script): 升级可灵视频生成使用官方 API 并添加失败重试机制

- 使用 AK/SK → JWT (HMAC-SHA256) 鉴权替代旧版 API Key
- 支持多种凭证来源:~/.config/kling/.credentials 或 config.json
- 更新 API 端点至官方规范 (v1/videos/image2video)
- 添加 `--retry-failed` 参数支持失败 item 状态重置和重试
- 更新 manifest 文档添加状态机和失败处理说明
- 调整模型名称和参数格式以匹配新 API
This commit is contained in:
2026-04-29 21:56:47 +08:00
parent 0b3ab3a2aa
commit 5619d753cc
4 changed files with 340 additions and 98 deletions

View File

@@ -17,8 +17,9 @@
"veoEnhancePrompt": true, "veoEnhancePrompt": true,
"veoEnableUpsample": true, "veoEnableUpsample": true,
"kelingApiBaseUrl": "https://api-beijing.klingai.com", "kelingApiBaseUrl": "https://api-beijing.klingai.com",
"kelingApiKey": "nreeg9bbKekdeenAma4KA3bkHMQkG4ND", "kelingApiKey": "AR4kMTEGaaM4d4QgJmLYMgHmEFABJPFE",
"kelingModel": "Kling-V2-5-Turbo", "kelingSecretAccessKey": "aBCrHLYTPPgMm3mnE8RBMAtmY9FLTGT3",
"kelingModel": "kling-v2-5-turbo",
"ossRegion": "oss-cn-hangzhou", "ossRegion": "oss-cn-hangzhou",
"ossAccessKeyId": "LTAI5tPV9Ag3csf41GZjaLTA", "ossAccessKeyId": "LTAI5tPV9Ag3csf41GZjaLTA",
"ossAccessKeySecret": "kDqlGeJTKw6tJtFYiaY8vQTFuVIQDs", "ossAccessKeySecret": "kDqlGeJTKw6tJtFYiaY8vQTFuVIQDs",

View File

@@ -28,13 +28,12 @@ node pipeline.js validate --manifest <path>
|------|------|------|--------| |------|------|------|--------|
| `account` | 账号 ID | account.json | **init 自动** | | `account` | 账号 ID | account.json | **init 自动** |
| `imageModel` | `gemini` / `mj` | 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 自动** | | `format` | 画幅:`9:16` / `16:9` | account.json | **init 自动** |
| `mode` | `single` 单图 / `framePair` 首尾帧 | CLI 参数 | **init 自动** | | `mode` | `single` 单图 / `framePair` 首尾帧 | CLI 参数 | **init 自动** |
| `references` | 参考图数组,从 account.json styles.*.references 搬入 | account.json | **init 自动** | | `references` | 参考图数组,从 account.json styles.*.references 搬入 | account.json | **init 自动** |
| `items` | 素材数组AI 提供创意内容) | CLI --items | **AI → init** | | `items` | 素材数组AI 提供创意内容) | CLI --items | **AI → init** |
**init 自动继承的字段不需要 AI 关心,不会出错。**
--- ---
@@ -82,6 +81,85 @@ node pipeline.js validate --manifest <path>
--- ---
## 状态机
### 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 <path> --phase images --retry-failed
# 需要换 prompt → 先改 item.imagePrompt再重试
# (改完后跑上面同一条命令)
```
**视频生成失败**videos 阶段 partial
```bash
# API 临时故障、网络超时 → 直接重试
node pipeline.js run --manifest <path> --phase videos --retry-failed
# 提示词问题 → 先改 item.videoPrompt再重试
# (改完后跑上面同一条命令)
# 视频模型不可用 → 改 manifest.videoModel 或 account.json再重试
```
**全阶段重试**
```bash
node pipeline.js run --manifest <path> --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 额外字段: `mode: "framePair"` 时,`imagePrompt` 作为起始帧,每个 item 额外字段:

View File

@@ -1,25 +1,25 @@
#!/usr/bin/env node #!/usr/bin/env node
/** /**
* Kling Video Generator - 图生视频工具(可灵模型 * Kling Video Generator - 图生视频(官方可灵 API
* *
* 功能: * 使用官方 Kling API (api-beijing.klingai.com) 进行图生视频
* - 提交图生视频任务Kling 模型) * AK/SK → JWT (HMAC-SHA256) 鉴权
* - 支持单图和首尾帧模式
* - 轮询直到完成60-300秒
* - 失败自动优化提示词重试最多3次
* - 批量并行生成 + manifest.json 文案透传
* *
* 用法: * 凭证来源(优先级):
* node kling-video-generator.js --image ./ref.jpg --prompt "zoom in slowly" -o ./output * 1. ~/.config/kling/.credentials (klingai skill 存储)
* node kling-video-generator.js --image ./first.jpg --last-frame ./last.jpg --prompt "transition" -o ./output * 2. config.json 的 kelingAccessKeyId + kelingSecretAccessKey
* (向下兼容 kelingApiKey 作为 AK)
*
* 用法:
* node kling-video-generator.js --image <url> --prompt "zoom in" -o ./output
* node kling-video-generator.js --image <url> --last-frame <url> --prompt "过渡" -o ./output
* node kling-video-generator.js batch ./manifest.json -o ./output * node kling-video-generator.js batch ./manifest.json -o ./output
*/ */
const fs = require('fs') const fs = require('fs')
const path = require('path') const path = require('path')
const https = require('https') const crypto = require('crypto')
const http = require('http')
// ============================================================================ // ============================================================================
// 配置 // 配置
@@ -36,14 +36,112 @@ function loadConfig() {
const cfg = loadConfig() const cfg = loadConfig()
const Config = { const Config = {
baseUrl: cfg.kelingApiBaseUrl , apiBase: (cfg.kelingApiBaseUrl || 'https://api-beijing.klingai.com').replace(/\/+$/, ''),
apiKey: cfg.kelingApiKey || '', model: cfg.kelingModel || 'kling-v3',
model: cfg.kelingModel || 'Kling-V2-5-Turbo',
pollInterval: 10000, pollInterval: 10000,
maxPollTime: 600000, maxPollTime: 600000,
maxRetries: 3, 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 中添加 kelingSecretAccessKeykelingApiKey 作为 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 = { const KlingApi = {
async create(imageUrl, prompt, options = {}) { async create(imageUrl, prompt, options = {}) {
const { const {
aspectRatio = '9:16',
model = Config.model, model = Config.model,
duration = 5,
mode = 'std',
lastFrameUrl = '', lastFrameUrl = '',
} = options } = options
const images = [] const creds = loadCredentials()
if (imageUrl) images.push(imageUrl) const token = makeJwt(creds.ak, creds.sk)
if (lastFrameUrl) images.push(lastFrameUrl)
const mode = lastFrameUrl ? '首尾帧' : '单图'
const body = { const body = {
model, model_name: model,
image: imageUrl,
prompt, prompt,
images, duration: String(duration),
aspect_ratio: aspectRatio, 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(` 模型: ${model}`)
console.log(` 提示词: ${prompt.substring(0, 80)}...`) console.log(` 提示词: ${prompt.substring(0, 80)}...`)
if (lastFrameUrl) { if (lastFrameUrl) {
@@ -115,79 +219,96 @@ const KlingApi = {
} else { } else {
console.log(` 参考图: ${imageUrl ? imageUrl.substring(0, 60) + '...' : '无'}`) 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', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Accept': 'application/json', 'Authorization': `Bearer ${token}`,
'Authorization': `Bearer ${Config.apiKey}`,
}, },
body: JSON.stringify(body), body: JSON.stringify(body),
}) })
const result = await res.json() const json = await res.json()
if (!result.id) { if (json.code !== undefined && json.code !== 0 && json.code !== 200) {
throw new Error(`Kling 提交失败: ${JSON.stringify(result)}`) throw new Error(`可灵 API 错误 (code=${json.code}): ${json.message || JSON.stringify(json)}`)
} }
console.log(` 任务 ID: ${result.id}`) const data = json.data || {}
return result.id const taskId = data.task_id
if (!taskId) {
throw new Error(`可灵提交失败: ${JSON.stringify(json)}`)
}
console.log(` 任务 ID: ${taskId}`)
return taskId
}, },
async query(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: { headers: {
'Authorization': `Bearer ${Config.apiKey}`, 'Authorization': `Bearer ${token}`,
'Accept': 'application/json', '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) { async poll(taskId) {
const startTime = Date.now() 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) { 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(`\n✅ 视频生成完成!`)
console.log(` 视频: ${task.video_url}`) console.log(` 视频: ${videos[0].url}`)
return { return { success: true, videoUrl: videos[0].url }
success: true,
videoUrl: task.video_url,
}
} }
if (task.status === 'failed') { if (status === 'failed') {
throw new Error(task.error || task.message || 'Kling 生成失败') throw new Error(data.task_status_msg || '可灵生成失败')
} }
const progress = task.progress || 0 if (status !== lastStatus) {
if (progress !== lastProgress) { lastStatus = status
lastProgress = progress
const elapsed = Math.round((Date.now() - startTime) / 1000) 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)) 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) { async function download(url, outputPath) {
const protocol = url.startsWith('https') ? https : http const protocol = url.startsWith('https') ? https : http
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@@ -213,9 +334,8 @@ async function download(url, outputPath) {
// ============================================================================ // ============================================================================
async function generate(imageUrl, prompt, options = {}) { 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 }) fs.mkdirSync(outputDir, { recursive: true })
let currentPrompt = prompt let currentPrompt = prompt
@@ -229,7 +349,10 @@ async function generate(imageUrl, prompt, options = {}) {
console.log(` 新提示词: ${currentPrompt}`) 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 result = await KlingApi.poll(taskId)
const timestamp = new Date().toISOString().replace(/[:.]/g, '-') 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 = {}) { async function batchGenerate(tasks, options = {}) {
const { outputDir = './output' } = options const { outputDir = './output', concurrency = 2, duration = 5, mode = 'std' } = options
let aspectRatio = options.aspectRatio || '9:16'
const concurrency = options.concurrency || 2
if (!Config.apiKey) throw new Error('未配置 kelingApiKey请在 config.json 中添加')
fs.mkdirSync(outputDir, { recursive: true }) fs.mkdirSync(outputDir, { recursive: true })
// 支持 manifest 格式 // 支持 manifest 格式
if (tasks.items && Array.isArray(tasks.items)) { if (tasks.items && Array.isArray(tasks.items)) {
if (tasks.format || tasks.defaultFormat) {
aspectRatio = tasks.format || tasks.defaultFormat || aspectRatio
}
tasks = tasks.items.map(item => ({ tasks = tasks.items.map(item => ({
image: item.url || item.image || '', image: item.url || item.image || '',
prompt: item.videoPrompt || item.prompt || 'cinematic motion', prompt: item.videoPrompt || item.prompt || 'cinematic motion',
@@ -289,8 +406,8 @@ async function batchGenerate(tasks, options = {}) {
} }
// Phase 1: 并行提交 // Phase 1: 并行提交
const mode = tasks.some(t => t.lastFrameUrl) ? '首尾帧' : '单图' const modeLabel = tasks.some(t => t.lastFrameUrl) ? '首尾帧' : '单图'
console.log(`\n📡 并行提交 ${tasks.length} Kling 视频任务(并发: ${concurrency},模式: ${mode}...`) console.log(`\n📡 并行提交 ${tasks.length}可灵视频任务(并发: ${concurrency},模式: ${modeLabel}...`)
const submitted = [] const submitted = []
for (let i = 0; i < tasks.length; i += concurrency) { for (let i = 0; i < tasks.length; i += concurrency) {
@@ -301,7 +418,9 @@ async function batchGenerate(tasks, options = {}) {
const prompt = task.videoPrompt || task.prompt const prompt = task.videoPrompt || task.prompt
console.log(` [${idx + 1}/${tasks.length}] 提交: ${prompt.substring(0, 50)}...`) console.log(` [${idx + 1}/${tasks.length}] 提交: ${prompt.substring(0, 50)}...`)
try { 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 } return { idx, taskId, task, error: null }
} catch (err) { } catch (err) {
console.error(` [${idx + 1}] 提交失败: ${err.message}`) console.error(` [${idx + 1}] 提交失败: ${err.message}`)
@@ -328,7 +447,10 @@ async function batchGenerate(tasks, options = {}) {
const pollResults = await Promise.allSettled( const pollResults = await Promise.allSettled(
pendingTasks.map(async ({ idx, taskId, task }) => { pendingTasks.map(async ({ idx, taskId, task }) => {
const prompt = task.videoPrompt || task.prompt 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 } return { idx, ...result, task }
}) })
) )
@@ -374,7 +496,7 @@ async function batchGenerate(tasks, options = {}) {
if (manifestItems.length > 0 && !options.skipManifestWrite) { if (manifestItems.length > 0 && !options.skipManifestWrite) {
const manifestPath = path.join(outputDir, 'manifest.json') const manifestPath = path.join(outputDir, 'manifest.json')
fs.writeFileSync(manifestPath, JSON.stringify({ items: manifestItems }, null, 2)) fs.writeFileSync(manifestPath, JSON.stringify({ items: manifestItems }, null, 2))
console.log(` 已生成 manifest.json${manifestItems.length},文案与视频对应`) console.log(` 已生成 manifest.json${manifestItems.length} 条)`)
} }
return results return results
@@ -396,7 +518,7 @@ async function pollWithRetry(taskId, prompt, options = {}) {
currentTaskId = await KlingApi.create( currentTaskId = await KlingApi.create(
options.imageUrl || '', options.imageUrl || '',
currentPrompt, 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() { function showHelp() {
console.log(` console.log(`
🎬 Kling Video Generator - 图生视频工具(可灵模型 🎬 Kling Video Generator - 图生视频(官方可灵 API
用法: 用法:
node kling-video-generator.js --image <url> --prompt "指令" [options] node kling-video-generator.js --image <url> --prompt "指令" [options]
@@ -441,25 +563,21 @@ function showHelp() {
选项: 选项:
-o, --output <dir> 输出目录 (默认: ./output) -o, --output <dir> 输出目录 (默认: ./output)
-a, --ar <ratio> 宽高比 (默认: 9:16) --duration <s> 视频时长 5 或 10 (默认: 5)
--mode <mode> 画质 std/pro (默认: std)
--model <model> 模型名称 (默认: ${Config.model}) --model <model> 模型名称 (默认: ${Config.model})
--last-frame <url> 结束帧 URL首尾帧模式 --last-frame <url> 结束帧 URL首尾帧模式
--retries <n> 失败重试次数 (默认: 3) --retries <n> 失败重试次数 (默认: 3)
-h, --help 帮助 -h, --help 帮助
模式: 凭证:
单图模式: --image <url> --prompt "运动描述" 需要在 config.json 中配置:
首尾帧模式: --image <首帧url> --last-frame <尾帧url> --prompt "过渡描述" kelingApiBaseUrl: "https://api-beijing.klingai.com"
kelingApiKey: "<你的 Access Key ID>"
kelingSecretAccessKey: "<你的 Secret Access Key>"
kelingModel: "kling-v3"
示例: 或使用 klingai skill 的 ~/.config/kling/.credentials
# 单图
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
`) `)
} }
@@ -472,13 +590,13 @@ async function main() {
} }
let command = 'single' let command = 'single'
let params = []
const options = { const options = {
outputDir: './output', outputDir: './output',
aspectRatio: '9:16',
imageUrl: '', imageUrl: '',
lastFrameUrl: '', lastFrameUrl: '',
prompt: '', prompt: '',
duration: 5,
mode: 'std',
} }
let i = 0 let i = 0
@@ -487,12 +605,16 @@ async function main() {
i = 1 i = 1
} }
const params = []
while (i < args.length) { while (i < args.length) {
const arg = args[i] const arg = args[i]
if (arg === '-o' || arg === '--output') { if (arg === '-o' || arg === '--output') {
options.outputDir = args[++i] options.outputDir = args[++i]
} else if (arg === '-a' || arg === '--ar') { } else if (arg === '--duration') {
options.aspectRatio = args[++i] options.duration = parseInt(args[++i], 10)
} else if (arg === '--mode') {
options.mode = args[++i]
} else if (arg === '--model') { } else if (arg === '--model') {
Config.model = args[++i] Config.model = args[++i]
} else if (arg === '--image') { } else if (arg === '--image') {

View File

@@ -531,6 +531,46 @@ async function runPipeline(manifestPath, options) {
manifest.pipeline = { phases: {} } 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(' → ')}`) log('pipeline', `阶段: ${phases.join(' → ')}`)
const phaseHandlers = { 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] === '--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] === '--phase' && argv[i + 1]) args.phases = argv[++i].split(',')
else if (argv[i] === '--resume') args.resume = true 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] === '--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' && argv[i + 1]) args.items = argv[++i]
else if (argv[i] === '--items-file' && argv[i + 1]) args.itemsFile = 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 (command === 'run') {
if (!args.manifest) { console.error('用法: pipeline.js run --manifest <path> [--account id] [--phase p1,p2] [--resume]'); process.exit(1) } if (!args.manifest) { console.error('用法: pipeline.js run --manifest <path> [--account id] [--phase p1,p2] [--resume] [--retry-failed]'); process.exit(1) }
await runPipeline(args.manifest, args) await runPipeline(args.manifest, args)
return return
} }
@@ -1066,7 +1107,7 @@ async function main() {
console.log(' pipeline.js validate-account --account <id>') console.log(' pipeline.js validate-account --account <id>')
console.log(' pipeline.js init --account <id> --mode <single|framePair> --items <JSON> [--items-file <path>]') console.log(' pipeline.js init --account <id> --mode <single|framePair> --items <JSON> [--items-file <path>]')
console.log(' pipeline.js validate --manifest <path>') console.log(' pipeline.js validate --manifest <path>')
console.log(' pipeline.js run --manifest <path> [--account id] [--phase p1,p2] [--resume]') console.log(' pipeline.js run --manifest <path> [--account id] [--phase p1,p2] [--resume] [--retry-failed]')
console.log(' pipeline.js status --manifest <path>') console.log(' pipeline.js status --manifest <path>')
console.log('') console.log('')
console.log('Manifest 路径约定: output/{account}_{date}_{NNN}/manifest.json同天自增序号') console.log('Manifest 路径约定: output/{account}_{date}_{NNN}/manifest.json同天自增序号')