Files
video-create/.claude/skills/video-from-script/scripts/kling-video-generator.js

668 lines
21 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
/**
* Kling Video Generator - 图生视频(官方可灵 API
*
* 使用官方 Kling API (api-beijing.klingai.com) 进行图生视频
* AK/SK → JWT (HMAC-SHA256) 鉴权
*
* 凭证来源(优先级):
* 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 crypto = require('crypto')
// ============================================================================
// 配置
// ============================================================================
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 = {
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 中添加 kelingSecretAccessKeykelingApiKey 作为 AK\n' +
' 2. 运行可灵绑定: node kling.mjs account --bind-url\n' +
' 3. 在 ~/.config/kling/.credentials 中配置 AK/SK'
)
}
// ============================================================================
// 提示词优化(失败时自动调整)
// ============================================================================
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'
}
// ============================================================================
// 官方可灵 API
// ============================================================================
const KlingApi = {
async create(imageUrl, prompt, options = {}) {
const {
model = Config.model,
duration = 5,
mode = 'std',
lastFrameUrl = '',
} = options
const creds = loadCredentials()
const token = makeJwt(creds.ak, creds.sk)
const body = {
model_name: model,
image: imageUrl,
prompt,
duration: String(duration),
mode,
multi_shot: false,
}
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) {
console.log(` 起始帧: ${imageUrl.substring(0, 60)}...`)
console.log(` 结束帧: ${lastFrameUrl.substring(0, 60)}...`)
} else {
console.log(` 参考图: ${imageUrl ? imageUrl.substring(0, 60) + '...' : '无'}`)
}
console.log(` 时长: ${duration}s | 画质: ${mode}`)
const res = await fetch(`${Config.apiBase}/v1/videos/image2video`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify(body),
})
const json = await res.json()
if (json.code !== undefined && json.code !== 0 && json.code !== 200) {
throw new Error(`可灵 API 错误 (code=${json.code}): ${json.message || JSON.stringify(json)}`)
}
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 creds = loadCredentials()
const token = makeJwt(creds.ak, creds.sk)
const res = await fetch(`${Config.apiBase}/v1/videos/image2video/${taskId}`, {
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/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 lastStatus = ''
console.log(`\n⏳ 等待可灵视频生成(预计 60-300 秒)...`)
while (Date.now() - startTime < Config.maxPollTime) {
const data = await KlingApi.query(taskId)
const status = data.task_status || ''
if (status === 'succeed') {
const videos = data.task_result?.videos || []
if (videos.length === 0) throw new Error('可灵生成成功但未返回视频')
console.log(`\n✅ 视频生成完成!`)
console.log(` 视频: ${videos[0].url}`)
return { success: true, videoUrl: videos[0].url }
}
if (status === 'failed') {
throw new Error(data.task_status_msg || '可灵生成失败')
}
if (status !== lastStatus) {
lastStatus = status
const elapsed = Math.round((Date.now() - startTime) / 1000)
console.log(` 状态: ${status} 已等待: ${elapsed}s`)
}
await new Promise(r => setTimeout(r, Config.pollInterval))
}
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) => {
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 = {}) {
const { outputDir = './output', duration = 5, mode = 'std' } = options
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}`)
}
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, '-')
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))
}
}
}
throw new Error(`可灵视频生成失败(已重试 ${Config.maxRetries} 次): ${lastError}`)
}
// ============================================================================
// 批量并行生成(支持 manifest.json
// ============================================================================
async function batchGenerate(tasks, options = {}) {
const { outputDir = './output', concurrency = 2, duration = 5, mode = 'std' } = options
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: 并行提交
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) {
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 {
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}`)
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
const result = await pollWithRetry(taskId, prompt, {
outputDir, duration, mode,
imageUrl: task.image, lastFrameUrl: task.lastFrameUrl,
})
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))
console.log(` 已生成 manifest.json${manifestItems.length} 条)`)
}
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,
{ duration: options.duration, mode: options.mode, lastFrameUrl: options.lastFrameUrl || '' }
)
}
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(`
🎬 Kling Video Generator - 图生视频(官方可灵 API
用法:
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)
--duration <s> 视频时长 5 或 10 (默认: 5)
--mode <mode> 画质 std/pro (默认: std)
--model <model> 模型名称 (默认: ${Config.model})
--last-frame <url> 结束帧 URL首尾帧模式
--retries <n> 失败重试次数 (默认: 3)
-h, --help 帮助
凭证:
需要在 config.json 中配置:
kelingApiBaseUrl: "https://api-beijing.klingai.com"
kelingApiKey: "<你的 Access Key ID>"
kelingSecretAccessKey: "<你的 Secret Access Key>"
kelingModel: "kling-v3"
或使用 klingai skill 的 ~/.config/kling/.credentials
`)
}
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: '',
duration: 5,
mode: 'std',
}
let i = 0
if (args[0] === 'batch') {
command = 'batch'
i = 1
}
const params = []
while (i < args.length) {
const arg = args[i]
if (arg === '-o' || arg === '--output') {
options.outputDir = 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') {
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)
})
}