2026-04-29 21:26:42 +08:00
|
|
|
|
#!/usr/bin/env node
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
2026-04-29 21:56:47 +08:00
|
|
|
|
* Kling Video Generator - 图生视频(官方可灵 API)
|
2026-04-29 21:26:42 +08:00
|
|
|
|
*
|
2026-04-29 21:56:47 +08:00
|
|
|
|
* 使用官方 Kling API (api-beijing.klingai.com) 进行图生视频
|
|
|
|
|
|
* AK/SK → JWT (HMAC-SHA256) 鉴权
|
2026-04-29 21:26:42 +08:00
|
|
|
|
*
|
2026-04-29 21:56:47 +08:00
|
|
|
|
* 凭证来源(优先级):
|
|
|
|
|
|
* 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
|
2026-04-29 21:26:42 +08:00
|
|
|
|
* node kling-video-generator.js batch ./manifest.json -o ./output
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
|
|
const fs = require('fs')
|
|
|
|
|
|
const path = require('path')
|
2026-04-29 21:56:47 +08:00
|
|
|
|
const crypto = require('crypto')
|
2026-04-29 21:26:42 +08:00
|
|
|
|
|
|
|
|
|
|
// ============================================================================
|
|
|
|
|
|
// 配置
|
|
|
|
|
|
// ============================================================================
|
|
|
|
|
|
|
|
|
|
|
|
function loadConfig() {
|
|
|
|
|
|
const configPath = path.join(__dirname, '..', '..', 'config.json')
|
|
|
|
|
|
if (fs.existsSync(configPath)) {
|
|
|
|
|
|
return JSON.parse(fs.readFileSync(configPath, 'utf-8'))
|
|
|
|
|
|
}
|
|
|
|
|
|
return {}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const cfg = loadConfig()
|
|
|
|
|
|
|
|
|
|
|
|
const Config = {
|
2026-04-29 21:56:47 +08:00
|
|
|
|
apiBase: (cfg.kelingApiBaseUrl || 'https://api-beijing.klingai.com').replace(/\/+$/, ''),
|
|
|
|
|
|
model: cfg.kelingModel || 'kling-v3',
|
2026-04-29 21:26:42 +08:00
|
|
|
|
pollInterval: 10000,
|
|
|
|
|
|
maxPollTime: 600000,
|
|
|
|
|
|
maxRetries: 3,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-29 21:56:47 +08:00
|
|
|
|
// ============================================================================
|
|
|
|
|
|
// 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'
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-29 21:26:42 +08:00
|
|
|
|
// ============================================================================
|
|
|
|
|
|
// 提示词优化(失败时自动调整)
|
|
|
|
|
|
// ============================================================================
|
|
|
|
|
|
|
|
|
|
|
|
const PromptOptimizer = {
|
|
|
|
|
|
optimize(prompt, failReason, attempt) {
|
|
|
|
|
|
let optimized = prompt
|
|
|
|
|
|
|
|
|
|
|
|
if (attempt === 1) {
|
|
|
|
|
|
optimized = simplifyPrompt(prompt)
|
|
|
|
|
|
console.log(` 重试策略: 简化提示词`)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (attempt === 2) {
|
|
|
|
|
|
optimized = `${simplifyPrompt(prompt)}, smooth motion, high quality`
|
|
|
|
|
|
console.log(` 重试策略: 简化 + 安全后缀`)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (attempt >= 3) {
|
|
|
|
|
|
optimized = extractCoreSubject(prompt)
|
|
|
|
|
|
console.log(` 重试策略: 极简提示词`)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return optimized
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function simplifyPrompt(prompt) {
|
|
|
|
|
|
const parts = prompt.split(',').map(s => s.trim())
|
|
|
|
|
|
return parts.slice(0, 3).join(', ')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function extractCoreSubject(prompt) {
|
|
|
|
|
|
const match = prompt.match(/^([^.!,]+)/)
|
|
|
|
|
|
return match ? match[1].trim() : 'cinematic motion'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ============================================================================
|
2026-04-29 21:56:47 +08:00
|
|
|
|
// 官方可灵 API
|
2026-04-29 21:26:42 +08:00
|
|
|
|
// ============================================================================
|
|
|
|
|
|
|
|
|
|
|
|
const KlingApi = {
|
|
|
|
|
|
async create(imageUrl, prompt, options = {}) {
|
|
|
|
|
|
const {
|
|
|
|
|
|
model = Config.model,
|
2026-04-29 21:56:47 +08:00
|
|
|
|
duration = 5,
|
|
|
|
|
|
mode = 'std',
|
2026-04-29 21:26:42 +08:00
|
|
|
|
lastFrameUrl = '',
|
|
|
|
|
|
} = options
|
|
|
|
|
|
|
2026-04-29 21:56:47 +08:00
|
|
|
|
const creds = loadCredentials()
|
|
|
|
|
|
const token = makeJwt(creds.ak, creds.sk)
|
2026-04-29 21:26:42 +08:00
|
|
|
|
|
|
|
|
|
|
const body = {
|
2026-04-29 21:56:47 +08:00
|
|
|
|
model_name: model,
|
|
|
|
|
|
image: imageUrl,
|
2026-04-29 21:26:42 +08:00
|
|
|
|
prompt,
|
2026-04-29 21:56:47 +08:00
|
|
|
|
duration: String(duration),
|
|
|
|
|
|
mode,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (lastFrameUrl) {
|
|
|
|
|
|
body.image_tail = lastFrameUrl
|
2026-04-29 21:26:42 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-29 21:56:47 +08:00
|
|
|
|
const modeLabel = lastFrameUrl ? '首尾帧' : '单图'
|
|
|
|
|
|
|
|
|
|
|
|
console.log(`\n📡 提交可灵视频任务 [${modeLabel}]`)
|
|
|
|
|
|
console.log(` API: ${Config.apiBase}`)
|
2026-04-29 21:26:42 +08:00
|
|
|
|
console.log(` 模型: ${model}`)
|
|
|
|
|
|
console.log(` 提示词: ${prompt.substring(0, 80)}...`)
|
|
|
|
|
|
if (lastFrameUrl) {
|
|
|
|
|
|
console.log(` 起始帧: ${imageUrl.substring(0, 60)}...`)
|
|
|
|
|
|
console.log(` 结束帧: ${lastFrameUrl.substring(0, 60)}...`)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
console.log(` 参考图: ${imageUrl ? imageUrl.substring(0, 60) + '...' : '无'}`)
|
|
|
|
|
|
}
|
2026-04-29 21:56:47 +08:00
|
|
|
|
console.log(` 时长: ${duration}s | 画质: ${mode}`)
|
2026-04-29 21:26:42 +08:00
|
|
|
|
|
2026-04-29 21:56:47 +08:00
|
|
|
|
const res = await fetch(`${Config.apiBase}/v1/videos/image2video`, {
|
2026-04-29 21:26:42 +08:00
|
|
|
|
method: 'POST',
|
|
|
|
|
|
headers: {
|
|
|
|
|
|
'Content-Type': 'application/json',
|
2026-04-29 21:56:47 +08:00
|
|
|
|
'Authorization': `Bearer ${token}`,
|
2026-04-29 21:26:42 +08:00
|
|
|
|
},
|
|
|
|
|
|
body: JSON.stringify(body),
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-04-29 21:56:47 +08:00
|
|
|
|
const json = await res.json()
|
2026-04-29 21:26:42 +08:00
|
|
|
|
|
2026-04-29 21:56:47 +08:00
|
|
|
|
if (json.code !== undefined && json.code !== 0 && json.code !== 200) {
|
|
|
|
|
|
throw new Error(`可灵 API 错误 (code=${json.code}): ${json.message || JSON.stringify(json)}`)
|
2026-04-29 21:26:42 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-29 21:56:47 +08:00
|
|
|
|
const data = json.data || {}
|
|
|
|
|
|
const taskId = data.task_id
|
|
|
|
|
|
|
|
|
|
|
|
if (!taskId) {
|
|
|
|
|
|
throw new Error(`可灵提交失败: ${JSON.stringify(json)}`)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
console.log(` 任务 ID: ${taskId}`)
|
|
|
|
|
|
return taskId
|
2026-04-29 21:26:42 +08:00
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
async query(taskId) {
|
2026-04-29 21:56:47 +08:00
|
|
|
|
const creds = loadCredentials()
|
|
|
|
|
|
const token = makeJwt(creds.ak, creds.sk)
|
|
|
|
|
|
|
|
|
|
|
|
const res = await fetch(`${Config.apiBase}/v1/videos/image2video/${taskId}`, {
|
2026-04-29 21:26:42 +08:00
|
|
|
|
headers: {
|
2026-04-29 21:56:47 +08:00
|
|
|
|
'Authorization': `Bearer ${token}`,
|
|
|
|
|
|
'Content-Type': 'application/json',
|
2026-04-29 21:26:42 +08:00
|
|
|
|
},
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-04-29 21:56:47 +08:00
|
|
|
|
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 || {}
|
2026-04-29 21:26:42 +08:00
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
async poll(taskId) {
|
|
|
|
|
|
const startTime = Date.now()
|
2026-04-29 21:56:47 +08:00
|
|
|
|
let lastStatus = ''
|
2026-04-29 21:26:42 +08:00
|
|
|
|
|
2026-04-29 21:56:47 +08:00
|
|
|
|
console.log(`\n⏳ 等待可灵视频生成(预计 60-300 秒)...`)
|
2026-04-29 21:26:42 +08:00
|
|
|
|
|
|
|
|
|
|
while (Date.now() - startTime < Config.maxPollTime) {
|
2026-04-29 21:56:47 +08:00
|
|
|
|
const data = await KlingApi.query(taskId)
|
|
|
|
|
|
const status = data.task_status || ''
|
2026-04-29 21:26:42 +08:00
|
|
|
|
|
2026-04-29 21:56:47 +08:00
|
|
|
|
if (status === 'succeed') {
|
|
|
|
|
|
const videos = data.task_result?.videos || []
|
|
|
|
|
|
if (videos.length === 0) throw new Error('可灵生成成功但未返回视频')
|
2026-04-29 21:26:42 +08:00
|
|
|
|
console.log(`\n✅ 视频生成完成!`)
|
2026-04-29 21:56:47 +08:00
|
|
|
|
console.log(` 视频: ${videos[0].url}`)
|
|
|
|
|
|
return { success: true, videoUrl: videos[0].url }
|
2026-04-29 21:26:42 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-29 21:56:47 +08:00
|
|
|
|
if (status === 'failed') {
|
|
|
|
|
|
throw new Error(data.task_status_msg || '可灵生成失败')
|
2026-04-29 21:26:42 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-29 21:56:47 +08:00
|
|
|
|
if (status !== lastStatus) {
|
|
|
|
|
|
lastStatus = status
|
2026-04-29 21:26:42 +08:00
|
|
|
|
const elapsed = Math.round((Date.now() - startTime) / 1000)
|
2026-04-29 21:56:47 +08:00
|
|
|
|
console.log(` 状态: ${status} 已等待: ${elapsed}s`)
|
2026-04-29 21:26:42 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
await new Promise(r => setTimeout(r, Config.pollInterval))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-29 21:56:47 +08:00
|
|
|
|
throw new Error(`可灵生成超时 (${Config.maxPollTime / 1000}s)`)
|
2026-04-29 21:26:42 +08:00
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ============================================================================
|
2026-04-29 21:56:47 +08:00
|
|
|
|
// 图片下载
|
2026-04-29 21:26:42 +08:00
|
|
|
|
// ============================================================================
|
|
|
|
|
|
|
2026-04-29 21:56:47 +08:00
|
|
|
|
const https = require('https')
|
|
|
|
|
|
const http = require('http')
|
|
|
|
|
|
|
2026-04-29 21:26:42 +08:00
|
|
|
|
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)
|
|
|
|
|
|
})
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ============================================================================
|
|
|
|
|
|
// 核心流程(单任务带重试)
|
|
|
|
|
|
// ============================================================================
|
|
|
|
|
|
|
|
|
|
|
|
async function generate(imageUrl, prompt, options = {}) {
|
2026-04-29 21:56:47 +08:00
|
|
|
|
const { outputDir = './output', duration = 5, mode = 'std' } = options
|
2026-04-29 21:26:42 +08:00
|
|
|
|
|
|
|
|
|
|
fs.mkdirSync(outputDir, { recursive: true })
|
|
|
|
|
|
|
|
|
|
|
|
let currentPrompt = prompt
|
|
|
|
|
|
let lastError = null
|
|
|
|
|
|
|
|
|
|
|
|
for (let attempt = 0; attempt <= Config.maxRetries; attempt++) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
if (attempt > 0) {
|
|
|
|
|
|
currentPrompt = PromptOptimizer.optimize(prompt, lastError, attempt)
|
|
|
|
|
|
console.log(`\n🔄 第 ${attempt} 次重试`)
|
|
|
|
|
|
console.log(` 新提示词: ${currentPrompt}`)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-29 21:56:47 +08:00
|
|
|
|
const taskId = await KlingApi.create(imageUrl, currentPrompt, {
|
|
|
|
|
|
duration, mode,
|
|
|
|
|
|
lastFrameUrl: options.lastFrameUrl,
|
|
|
|
|
|
})
|
2026-04-29 21:26:42 +08:00
|
|
|
|
const result = await KlingApi.poll(taskId)
|
|
|
|
|
|
|
|
|
|
|
|
const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
|
|
|
|
|
|
const videoFile = path.join(outputDir, `${timestamp}_kling.mp4`)
|
|
|
|
|
|
await download(result.videoUrl, videoFile)
|
|
|
|
|
|
console.log(` 下载完成: ${videoFile}`)
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
success: true,
|
|
|
|
|
|
taskId,
|
|
|
|
|
|
prompt: currentPrompt,
|
|
|
|
|
|
originalPrompt: prompt,
|
|
|
|
|
|
attempts: attempt + 1,
|
|
|
|
|
|
files: [videoFile],
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
lastError = err.message
|
|
|
|
|
|
console.error(` ❌ 第 ${attempt + 1} 次失败: ${err.message}`)
|
|
|
|
|
|
|
|
|
|
|
|
if (attempt < Config.maxRetries) {
|
|
|
|
|
|
console.log(` 等待 5 秒后重试...`)
|
|
|
|
|
|
await new Promise(r => setTimeout(r, 5000))
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-29 21:56:47 +08:00
|
|
|
|
throw new Error(`可灵视频生成失败(已重试 ${Config.maxRetries} 次): ${lastError}`)
|
2026-04-29 21:26:42 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ============================================================================
|
2026-04-29 21:56:47 +08:00
|
|
|
|
// 批量并行生成(支持 manifest.json)
|
2026-04-29 21:26:42 +08:00
|
|
|
|
// ============================================================================
|
|
|
|
|
|
|
|
|
|
|
|
async function batchGenerate(tasks, options = {}) {
|
2026-04-29 21:56:47 +08:00
|
|
|
|
const { outputDir = './output', concurrency = 2, duration = 5, mode = 'std' } = options
|
2026-04-29 21:26:42 +08:00
|
|
|
|
|
|
|
|
|
|
fs.mkdirSync(outputDir, { recursive: true })
|
|
|
|
|
|
|
|
|
|
|
|
// 支持 manifest 格式
|
|
|
|
|
|
if (tasks.items && Array.isArray(tasks.items)) {
|
|
|
|
|
|
tasks = tasks.items.map(item => ({
|
|
|
|
|
|
image: item.url || item.image || '',
|
|
|
|
|
|
prompt: item.videoPrompt || item.prompt || 'cinematic motion',
|
|
|
|
|
|
text: item.text || item.caption || '',
|
|
|
|
|
|
keyword: item.keyword || '',
|
|
|
|
|
|
keywordColor: item.keywordColor || '',
|
|
|
|
|
|
file: item.file || '',
|
|
|
|
|
|
lastFrameUrl: item.lastFrameUrl || '',
|
|
|
|
|
|
}))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Phase 1: 并行提交
|
2026-04-29 21:56:47 +08:00
|
|
|
|
const modeLabel = tasks.some(t => t.lastFrameUrl) ? '首尾帧' : '单图'
|
|
|
|
|
|
console.log(`\n📡 并行提交 ${tasks.length} 个可灵视频任务(并发: ${concurrency},模式: ${modeLabel})...`)
|
2026-04-29 21:26:42 +08:00
|
|
|
|
|
|
|
|
|
|
const submitted = []
|
|
|
|
|
|
for (let i = 0; i < tasks.length; i += concurrency) {
|
|
|
|
|
|
const batch = tasks.slice(i, i + concurrency)
|
|
|
|
|
|
const batchResults = await Promise.allSettled(
|
|
|
|
|
|
batch.map(async (task, j) => {
|
|
|
|
|
|
const idx = i + j
|
|
|
|
|
|
const prompt = task.videoPrompt || task.prompt
|
|
|
|
|
|
console.log(` [${idx + 1}/${tasks.length}] 提交: ${prompt.substring(0, 50)}...`)
|
|
|
|
|
|
try {
|
2026-04-29 21:56:47 +08:00
|
|
|
|
const taskId = await KlingApi.create(task.image, prompt, {
|
|
|
|
|
|
duration, mode, lastFrameUrl: task.lastFrameUrl,
|
|
|
|
|
|
})
|
2026-04-29 21:26:42 +08:00
|
|
|
|
return { idx, taskId, task, error: null }
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
console.error(` [${idx + 1}] 提交失败: ${err.message}`)
|
|
|
|
|
|
return { idx, taskId: null, task, error: err.message }
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
)
|
|
|
|
|
|
submitted.push(...batchResults.map(r => r.value || r.reason))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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
|
2026-04-29 21:56:47 +08:00
|
|
|
|
const result = await pollWithRetry(taskId, prompt, {
|
|
|
|
|
|
outputDir, duration, mode,
|
|
|
|
|
|
imageUrl: task.image, lastFrameUrl: task.lastFrameUrl,
|
|
|
|
|
|
})
|
2026-04-29 21:26:42 +08:00
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
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 })
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const ok = results.filter(r => r.success).length
|
|
|
|
|
|
console.log(`\n✨ 批量完成: ${ok}/${tasks.length} 成功`)
|
|
|
|
|
|
|
|
|
|
|
|
// 输出 manifest.json
|
|
|
|
|
|
const manifestItems = results
|
|
|
|
|
|
.filter(r => r.success && r.files && r.files.length > 0)
|
|
|
|
|
|
.map(r => {
|
|
|
|
|
|
const item = {
|
|
|
|
|
|
file: path.basename(r.files[0]),
|
|
|
|
|
|
duration: 6,
|
|
|
|
|
|
}
|
|
|
|
|
|
if (r.text) item.text = r.text
|
|
|
|
|
|
if (r.caption) item.caption = r.caption
|
|
|
|
|
|
if (r.keyword) item.keyword = r.keyword
|
|
|
|
|
|
if (r.keywordColor) item.keywordColor = r.keywordColor
|
|
|
|
|
|
return item
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
if (manifestItems.length > 0 && !options.skipManifestWrite) {
|
|
|
|
|
|
const manifestPath = path.join(outputDir, 'manifest.json')
|
|
|
|
|
|
fs.writeFileSync(manifestPath, JSON.stringify({ items: manifestItems }, null, 2))
|
2026-04-29 21:56:47 +08:00
|
|
|
|
console.log(` 已生成 manifest.json(${manifestItems.length} 条)`)
|
2026-04-29 21:26:42 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return results
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 轮询 + 失败重试(单任务)
|
|
|
|
|
|
*/
|
|
|
|
|
|
async function pollWithRetry(taskId, prompt, options = {}) {
|
|
|
|
|
|
let currentTaskId = taskId
|
|
|
|
|
|
let currentPrompt = prompt
|
|
|
|
|
|
let lastError = null
|
|
|
|
|
|
|
|
|
|
|
|
for (let attempt = 0; attempt <= Config.maxRetries; attempt++) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
if (attempt > 0) {
|
|
|
|
|
|
currentPrompt = PromptOptimizer.optimize(prompt, lastError, attempt)
|
|
|
|
|
|
console.log(`\n 🔄 重试 (任务 ${currentTaskId.substring(0, 8)}...): ${currentPrompt.substring(0, 50)}`)
|
|
|
|
|
|
currentTaskId = await KlingApi.create(
|
|
|
|
|
|
options.imageUrl || '',
|
|
|
|
|
|
currentPrompt,
|
2026-04-29 21:56:47 +08:00
|
|
|
|
{ duration: options.duration, mode: options.mode, lastFrameUrl: options.lastFrameUrl || '' }
|
2026-04-29 21:26:42 +08:00
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const result = await KlingApi.poll(currentTaskId)
|
|
|
|
|
|
|
|
|
|
|
|
const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
|
|
|
|
|
|
const videoFile = path.join(options.outputDir || './output', `${timestamp}_kling.mp4`)
|
|
|
|
|
|
await download(result.videoUrl, videoFile)
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
taskId: currentTaskId,
|
|
|
|
|
|
prompt: currentPrompt,
|
|
|
|
|
|
originalPrompt: prompt,
|
|
|
|
|
|
attempts: attempt + 1,
|
|
|
|
|
|
file: videoFile,
|
|
|
|
|
|
files: [videoFile],
|
|
|
|
|
|
duration: 6,
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
lastError = err.message
|
|
|
|
|
|
if (attempt < Config.maxRetries) {
|
|
|
|
|
|
await new Promise(r => setTimeout(r, 5000))
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
throw new Error(`重试 ${Config.maxRetries} 次后仍失败: ${lastError}`)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ============================================================================
|
|
|
|
|
|
// CLI
|
|
|
|
|
|
// ============================================================================
|
|
|
|
|
|
|
|
|
|
|
|
function showHelp() {
|
|
|
|
|
|
console.log(`
|
2026-04-29 21:56:47 +08:00
|
|
|
|
🎬 Kling Video Generator - 图生视频(官方可灵 API)
|
2026-04-29 21:26:42 +08:00
|
|
|
|
|
|
|
|
|
|
用法:
|
|
|
|
|
|
node kling-video-generator.js --image <url> --prompt "指令" [options]
|
|
|
|
|
|
node kling-video-generator.js --image <url> --last-frame <url> --prompt "过渡" [options]
|
|
|
|
|
|
node kling-video-generator.js batch <manifest.json> [options]
|
|
|
|
|
|
|
|
|
|
|
|
选项:
|
|
|
|
|
|
-o, --output <dir> 输出目录 (默认: ./output)
|
2026-04-29 21:56:47 +08:00
|
|
|
|
--duration <s> 视频时长 5 或 10 (默认: 5)
|
|
|
|
|
|
--mode <mode> 画质 std/pro (默认: std)
|
2026-04-29 21:26:42 +08:00
|
|
|
|
--model <model> 模型名称 (默认: ${Config.model})
|
|
|
|
|
|
--last-frame <url> 结束帧 URL(首尾帧模式)
|
|
|
|
|
|
--retries <n> 失败重试次数 (默认: 3)
|
|
|
|
|
|
-h, --help 帮助
|
|
|
|
|
|
|
2026-04-29 21:56:47 +08:00
|
|
|
|
凭证:
|
|
|
|
|
|
需要在 config.json 中配置:
|
|
|
|
|
|
kelingApiBaseUrl: "https://api-beijing.klingai.com"
|
|
|
|
|
|
kelingApiKey: "<你的 Access Key ID>"
|
|
|
|
|
|
kelingSecretAccessKey: "<你的 Secret Access Key>"
|
|
|
|
|
|
kelingModel: "kling-v3"
|
2026-04-29 21:26:42 +08:00
|
|
|
|
|
2026-04-29 21:56:47 +08:00
|
|
|
|
或使用 klingai skill 的 ~/.config/kling/.credentials
|
2026-04-29 21:26:42 +08:00
|
|
|
|
`)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function main() {
|
|
|
|
|
|
const args = process.argv.slice(2)
|
|
|
|
|
|
|
|
|
|
|
|
if (args.includes('-h') || args.includes('--help') || args.length === 0) {
|
|
|
|
|
|
showHelp()
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
let command = 'single'
|
|
|
|
|
|
const options = {
|
|
|
|
|
|
outputDir: './output',
|
|
|
|
|
|
imageUrl: '',
|
|
|
|
|
|
lastFrameUrl: '',
|
|
|
|
|
|
prompt: '',
|
2026-04-29 21:56:47 +08:00
|
|
|
|
duration: 5,
|
|
|
|
|
|
mode: 'std',
|
2026-04-29 21:26:42 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
let i = 0
|
|
|
|
|
|
if (args[0] === 'batch') {
|
|
|
|
|
|
command = 'batch'
|
|
|
|
|
|
i = 1
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-29 21:56:47 +08:00
|
|
|
|
const params = []
|
|
|
|
|
|
|
2026-04-29 21:26:42 +08:00
|
|
|
|
while (i < args.length) {
|
|
|
|
|
|
const arg = args[i]
|
|
|
|
|
|
if (arg === '-o' || arg === '--output') {
|
|
|
|
|
|
options.outputDir = args[++i]
|
2026-04-29 21:56:47 +08:00
|
|
|
|
} else if (arg === '--duration') {
|
|
|
|
|
|
options.duration = parseInt(args[++i], 10)
|
|
|
|
|
|
} else if (arg === '--mode') {
|
|
|
|
|
|
options.mode = args[++i]
|
2026-04-29 21:26:42 +08:00
|
|
|
|
} else if (arg === '--model') {
|
|
|
|
|
|
Config.model = args[++i]
|
|
|
|
|
|
} else if (arg === '--image') {
|
|
|
|
|
|
options.imageUrl = args[++i]
|
|
|
|
|
|
} else if (arg === '--last-frame') {
|
|
|
|
|
|
options.lastFrameUrl = args[++i]
|
|
|
|
|
|
} else if (arg === '--prompt') {
|
|
|
|
|
|
options.prompt = args[++i]
|
|
|
|
|
|
} else if (arg === '--retries') {
|
|
|
|
|
|
Config.maxRetries = parseInt(args[++i], 10)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
params.push(arg)
|
|
|
|
|
|
}
|
|
|
|
|
|
i++
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (command === 'batch') {
|
|
|
|
|
|
const filePath = params[0]
|
|
|
|
|
|
if (!filePath || !fs.existsSync(filePath)) {
|
|
|
|
|
|
console.error('请提供 manifest.json 路径')
|
|
|
|
|
|
process.exit(1)
|
|
|
|
|
|
}
|
|
|
|
|
|
const tasks = JSON.parse(fs.readFileSync(filePath, 'utf-8'))
|
|
|
|
|
|
await batchGenerate(tasks, options)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
if (!options.imageUrl) {
|
|
|
|
|
|
console.error('请提供 --image 参数(图片 URL)')
|
|
|
|
|
|
process.exit(1)
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!options.prompt) {
|
|
|
|
|
|
console.error('请提供 --prompt 参数')
|
|
|
|
|
|
process.exit(1)
|
|
|
|
|
|
}
|
|
|
|
|
|
await generate(options.imageUrl, options.prompt, options)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ============================================================================
|
|
|
|
|
|
// 导出
|
|
|
|
|
|
// ============================================================================
|
|
|
|
|
|
|
|
|
|
|
|
module.exports = { generate, batchGenerate, pollWithRetry, KlingApi, PromptOptimizer }
|
|
|
|
|
|
|
|
|
|
|
|
if (require.main === module) {
|
|
|
|
|
|
main().catch(err => {
|
|
|
|
|
|
console.error(`\n❌ 错误: ${err.message}`)
|
|
|
|
|
|
process.exit(1)
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|