#!/usr/bin/env node /** * Kling Video Generator - 图生视频工具(可灵模型) * * 功能: * - 提交图生视频任务(Kling 模型) * - 支持单图和首尾帧模式 * - 轮询直到完成(60-300秒) * - 失败自动优化提示词重试(最多3次) * - 批量并行生成 + manifest.json 文案透传 * * 用法: * 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 * 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') // ============================================================================ // 配置 // ============================================================================ 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 = { baseUrl: cfg.kelingApiBaseUrl , apiKey: cfg.kelingApiKey || '', model: cfg.kelingModel || 'Kling-V2-5-Turbo', pollInterval: 10000, maxPollTime: 600000, maxRetries: 3, } // ============================================================================ // 提示词优化(失败时自动调整) // ============================================================================ 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 { aspectRatio = '9:16', model = Config.model, lastFrameUrl = '', } = options const images = [] if (imageUrl) images.push(imageUrl) if (lastFrameUrl) images.push(lastFrameUrl) const mode = lastFrameUrl ? '首尾帧' : '单图' const body = { model, prompt, images, aspect_ratio: aspectRatio, } console.log(`\n📡 提交 Kling 视频任务 [${mode}]`) 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(` 画幅: ${aspectRatio}`) const res = await fetch(`${Config.baseUrl}/v1/video/create`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', 'Authorization': `Bearer ${Config.apiKey}`, }, body: JSON.stringify(body), }) const result = await res.json() if (!result.id) { throw new Error(`Kling 提交失败: ${JSON.stringify(result)}`) } console.log(` 任务 ID: ${result.id}`) return result.id }, async query(taskId) { const res = await fetch(`${Config.baseUrl}/v1/video/query?id=${taskId}`, { headers: { 'Authorization': `Bearer ${Config.apiKey}`, 'Accept': 'application/json', }, }) return await res.json() }, async poll(taskId) { const startTime = Date.now() let lastProgress = 0 console.log(`\n⏳ 等待 Kling 视频生成(预计 60-300 秒)...`) while (Date.now() - startTime < Config.maxPollTime) { const task = await KlingApi.query(taskId) if (task.status === 'completed') { console.log(`\n✅ 视频生成完成!`) console.log(` 视频: ${task.video_url}`) return { success: true, videoUrl: task.video_url, } } if (task.status === 'failed') { throw new Error(task.error || task.message || 'Kling 生成失败') } const progress = task.progress || 0 if (progress !== lastProgress) { lastProgress = progress const elapsed = Math.round((Date.now() - startTime) / 1000) process.stdout.write(` 进度: ${progress}% 已等待: ${elapsed}s 状态: ${task.status}\r`) } await new Promise(r => setTimeout(r, Config.pollInterval)) } throw new Error(`Kling 生成超时 (${Config.maxPollTime / 1000}s)`) }, } // ============================================================================ // 图片下载工具 // ============================================================================ 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', aspectRatio = '9:16' } = options if (!Config.apiKey) throw new Error('未配置 kelingApiKey,请在 config.json 中添加') 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, { aspectRatio, 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(`Kling 视频生成失败(已重试 ${Config.maxRetries} 次): ${lastError}`) } // ============================================================================ // 批量并行生成(支持 manifest.json 输入输出) // ============================================================================ async function batchGenerate(tasks, options = {}) { const { outputDir = './output' } = 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 }) // 支持 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', text: item.text || item.caption || '', keyword: item.keyword || '', keywordColor: item.keywordColor || '', file: item.file || '', lastFrameUrl: item.lastFrameUrl || '', })) } // Phase 1: 并行提交 const mode = tasks.some(t => t.lastFrameUrl) ? '首尾帧' : '单图' console.log(`\n📡 并行提交 ${tasks.length} 个 Kling 视频任务(并发: ${concurrency},模式: ${mode})...`) 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, { aspectRatio, 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, aspectRatio, 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, { aspectRatio: options.aspectRatio, 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 - 图生视频工具(可灵模型) 用法: 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) -a, --ar 宽高比 (默认: 9:16) --model 模型名称 (默认: ${Config.model}) --last-frame 结束帧 URL(首尾帧模式) --retries 失败重试次数 (默认: 3) -h, --help 帮助 模式: 单图模式: --image --prompt "运动描述" 首尾帧模式: --image <首帧url> --last-frame <尾帧url> --prompt "过渡描述" 示例: # 单图 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 `) } async function main() { const args = process.argv.slice(2) if (args.includes('-h') || args.includes('--help') || args.length === 0) { showHelp() return } let command = 'single' let params = [] const options = { outputDir: './output', aspectRatio: '9:16', imageUrl: '', lastFrameUrl: '', prompt: '', } let i = 0 if (args[0] === 'batch') { command = 'batch' i = 1 } 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 === '--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) }) }