Files
video-create/.claude/skills/video-from-script/scripts/kling-video-generator.js
2026-04-29 21:26:42 +08:00

545 lines
17 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 - 图生视频工具(可灵模型)
*
* 功能:
* - 提交图生视频任务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 <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)
-a, --ar <ratio> 宽高比 (默认: 9:16)
--model <model> 模型名称 (默认: ${Config.model})
--last-frame <url> 结束帧 URL首尾帧模式
--retries <n> 失败重试次数 (默认: 3)
-h, --help 帮助
模式:
单图模式: --image <url> --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)
})
}