#!/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 --prompt "zoom in" -o ./output * node kling-video-generator.js --image --last-frame --prompt "过渡" -o ./output * node kling-video-generator.js batch ./manifest.json -o ./output */ const fs = require('fs') const path = require('path') const 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 中添加 kelingSecretAccessKey(kelingApiKey 作为 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, } 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 --prompt "指令" [options] node kling-video-generator.js --image --last-frame --prompt "过渡" [options] node kling-video-generator.js batch [options] 选项: -o, --output 输出目录 (默认: ./output) --duration 视频时长 5 或 10 (默认: 5) --mode 画质 std/pro (默认: std) --model 模型名称 (默认: ${Config.model}) --last-frame 结束帧 URL(首尾帧模式) --retries 失败重试次数 (默认: 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) }) }