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:
@@ -28,13 +28,12 @@ node pipeline.js validate --manifest <path>
|
||||
|------|------|------|--------|
|
||||
| `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 <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 额外字段:
|
||||
|
||||
@@ -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 <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
|
||||
*/
|
||||
|
||||
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 <url> --prompt "指令" [options]
|
||||
@@ -441,25 +563,21 @@ function showHelp() {
|
||||
|
||||
选项:
|
||||
-o, --output <dir> 输出目录 (默认: ./output)
|
||||
-a, --ar <ratio> 宽高比 (默认: 9:16)
|
||||
--duration <s> 视频时长 5 或 10 (默认: 5)
|
||||
--mode <mode> 画质 std/pro (默认: std)
|
||||
--model <model> 模型名称 (默认: ${Config.model})
|
||||
--last-frame <url> 结束帧 URL(首尾帧模式)
|
||||
--retries <n> 失败重试次数 (默认: 3)
|
||||
-h, --help 帮助
|
||||
|
||||
模式:
|
||||
单图模式: --image <url> --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') {
|
||||
|
||||
@@ -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 <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)
|
||||
return
|
||||
}
|
||||
@@ -1066,7 +1107,7 @@ async function main() {
|
||||
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 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('')
|
||||
console.log('Manifest 路径约定: output/{account}_{date}_{NNN}/manifest.json(同天自增序号)')
|
||||
|
||||
Reference in New Issue
Block a user