feat(video-from-script): 集成可灵图像生成器并优化审查流程

- 新增 kling-image-generator.js 作为可灵 API 图像生成模块,支持文生图和图生图
- 在 pipeline.js 中集成可灵生图,支持在 images 阶段使用 kling 模型
- 更新 SKILL.md 文档:简化用户审查流程,支持"自行选图"模式
- 实现模型降级链(gemini → kling → mj → gemini),增强生图容错性
- 扩展 `--image-model` 参数支持,允许在重试时切换模型
This commit is contained in:
2026-04-30 00:49:33 +08:00
parent 5dd83fdb45
commit 8301c7b780
3 changed files with 243 additions and 5 deletions

View File

@@ -133,9 +133,16 @@ Step 2: Prompt 生成 + Manifest 初始化(分镜 + 风格 → 英文 prompts
- 首尾帧模式额外要求:每个 item 必须有 `lastFramePrompt``imagePrompt` 作为第一帧,不需要单独的 `firstFramePrompt`
- init 返回 manifest 路径,后续命令使用该路径
Step 3: 生图 → 人工审查
跑 images 阶段。完成后审查分辨率≥1024、风格一致性、构图、无水印。
不合格则删除/调 prompt 重跑,不进入下一步。
Step 3: 生图 → 审查
跑 images 阶段。完成后
- 用户指定"自行选图"→ Agent 自动检查数量对上文案数量就通过继续
- 否则 → 暂停,等用户审查。不合格则删除/调 prompt 重跑,不进入下一步
生图模型
- 支持模型gemini / mj / kling
- 降级链gemini → kling → mj → gemini循环
- 触发:连续失败→ Agent 换下一个模型重跑失败项
- 操作:`pipeline.js run --manifest <path> --phase images --retry-failed --image-model <新模型>`
Step 4: 上传 + 生视频(可选,图文成片跳过此步)
跑 upload + videos 阶段。首尾帧模式检查过渡连贯性。

View File

@@ -0,0 +1,210 @@
#!/usr/bin/env node
/**
* Kling Image Generator - 可灵文生图 / 图生图
*
* 配置来源: config.json 的 kelingApiKey + kelingSecretAccessKey + kelingApiBaseUrl
*
* 用法:
* node kling-image-generator.js "a cute cat" -o ./output
* node kling-image-generator.js "portrait" -r http://img.com/ref.jpg
*/
const fs = require('fs')
const path = require('path')
const https = require('https')
const http = require('http')
const crypto = require('crypto')
// ============================================================================
// 配置
// ============================================================================
const configPath = path.join(__dirname, '..', '..', 'config.json')
const cfg = fs.existsSync(configPath) ? JSON.parse(fs.readFileSync(configPath, 'utf-8')) : {}
const Config = {
apiBase: (cfg.kelingApiBaseUrl || 'https://api-beijing.klingai.com').replace(/\/+$/, ''),
ak: (cfg.kelingApiKey || '').trim(),
sk: (cfg.kelingSecretAccessKey || '').trim(),
model: cfg.kelingImageModel || 'kling-v2-1',
pollInterval: 5000,
maxPollTime: 300000,
}
// ============================================================================
// JWT 鉴权
// ============================================================================
function base64url(buf) {
return Buffer.from(buf).toString('base64')
.replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_')
}
function getToken() {
if (!Config.ak || !Config.sk) throw new Error('未配置可灵 API 凭证config.json 需要 kelingApiKey + kelingSecretAccessKey')
const header = base64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))
const now = Math.floor(Date.now() / 1000)
const payload = base64url(JSON.stringify({ iss: Config.ak, exp: now + 1800, nbf: now - 5 }))
const sig = base64url(crypto.createHmac('sha256', Config.sk).update(`${header}.${payload}`).digest())
return `${header}.${payload}.${sig}`
}
// ============================================================================
// 图片下载
// ============================================================================
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, (res) => {
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
file.close()
fs.unlinkSync(outputPath)
return download(res.headers.location, outputPath).then(resolve).catch(reject)
}
res.pipe(file)
file.on('finish', () => { file.close(); resolve(outputPath) })
}).on('error', (err) => {
file.close()
if (fs.existsSync(outputPath)) fs.unlinkSync(outputPath)
reject(err)
})
})
}
// ============================================================================
// 可灵图片 API
// ============================================================================
const KlingImageApi = {
async submit(prompt, options = {}) {
const { referenceImageUrl = '', aspectRatio = '' } = options
const token = getToken()
const body = { model_name: Config.model, prompt, n: 1 }
if (referenceImageUrl) body.image = referenceImageUrl
if (aspectRatio) body.aspect_ratio = aspectRatio
console.log(`\n📡 提交可灵图片任务`)
console.log(` 模型: ${Config.model}`)
console.log(` 提示词: ${prompt.substring(0, 80)}...`)
if (referenceImageUrl) console.log(` 参考图: ${referenceImageUrl.substring(0, 60)}...`)
const res = await fetch(`${Config.apiBase}/v1/images/generations`, {
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 taskId = (json.data || {}).task_id
if (!taskId) throw new Error(`可灵图片提交失败: ${JSON.stringify(json)}`)
console.log(` 任务 ID: ${taskId}`)
return taskId
},
async poll(taskId) {
const startTime = Date.now()
console.log(`\n⏳ 等待可灵生成...`)
while (Date.now() - startTime < Config.maxPollTime) {
const token = getToken()
const res = await fetch(`${Config.apiBase}/v1/images/generations/${taskId}`, {
headers: { 'Authorization': `Bearer ${token}` },
})
const json = await res.json()
const data = json.data || {}
const status = data.task_status
if (status === 'succeed') {
const images = data.task_result?.images || []
if (images.length > 0) {
console.log(`\n✅ 可灵图片生成完成!`)
return { success: true, imageUrl: images[0].url }
}
throw new Error('可灵返回成功但无图片')
}
if (status === 'failed') {
throw new Error(`可灵图片生成失败: ${data.task_status_msg || '未知原因'}`)
}
process.stdout.write(` 状态: ${status || 'processing'}\r`)
await new Promise(r => setTimeout(r, Config.pollInterval))
}
throw new Error(`可灵图片生成超时 (${Config.maxPollTime / 1000}s)`)
},
}
// ============================================================================
// 核心流程
// ============================================================================
async function generate(prompt, options = {}) {
const { outputDir = './output', referenceImageUrl = '' } = options
fs.mkdirSync(outputDir, { recursive: true })
const taskId = await KlingImageApi.submit(prompt, { referenceImageUrl })
const result = await KlingImageApi.poll(taskId)
const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
const filePath = path.join(outputDir, `${timestamp}_kling.png`)
await download(result.imageUrl, filePath)
console.log(` 下载完成: ${filePath}`)
return { success: true, taskId, imageUrl: result.imageUrl, savedFiles: [filePath] }
}
// ============================================================================
// CLI
// ============================================================================
async function main() {
const args = process.argv.slice(2)
if (args.includes('-h') || args.includes('--help') || args.length === 0) {
console.log(`
用法: node kling-image-generator.js <prompt> [options]
选项:
-o, --output <dir> 输出目录 (默认: ./output)
-r, --ref <url> 参考图 URL
-h, --help 帮助
`)
return
}
const options = { outputDir: './output', referenceImageUrl: '' }
const params = []
let i = 0
while (i < args.length) {
const arg = args[i]
if (arg === '-o' || arg === '--output') { options.outputDir = args[++i] }
else if (arg === '-r' || arg === '--ref') { options.referenceImageUrl = args[++i] }
else { params.push(arg) }
i++
}
const prompt = params.join(' ')
if (!prompt) { console.error('请提供提示词'); process.exit(1) }
await generate(prompt, options)
}
module.exports = { generate, KlingImageApi }
if (require.main === module) {
main().catch(err => {
console.error(`\n❌ 错误: ${err.message}`)
process.exit(1)
})
}

View File

@@ -156,7 +156,7 @@ async function phaseImages(manifest, manifestPath, options) {
if (items.length === 0) { log('images', '无待处理 item跳过'); return }
const accountConfig = options.accountConfig || {}
let model = manifest.imageModel || accountConfig.imageModel || 'gemini'
let model = options.imageModel || manifest.imageModel || accountConfig.imageModel || 'gemini'
const ratio = manifest.format || accountConfig.defaultFormat || '9:16'
// 首尾帧模式MJ 降级为 GeminiMJ 出4张候选图无法一一对应首尾帧
@@ -217,8 +217,22 @@ async function phaseImages(manifest, manifestPath, options) {
item.file = item.candidates[0]
log('images', `[${idx}/${items.length}] ${result.files.length} 张候选默认选第1张`)
}
} else if (model === 'kling') {
const { generate: klingGen } = require('./kling-image-generator')
const klingOpts = { outputDir: imagesDir, aspectRatio: ratio }
if (refs.urls.length > 0) {
klingOpts.referenceImageUrl = refs.urls[0]
}
log('images', `[${idx}/${items.length}] 可灵生图: ${item.imagePrompt.substring(0, 60)}...`)
result = await klingGen(item.imagePrompt, klingOpts)
if (result.savedFiles && result.savedFiles.length > 0) {
item.file = renameGeneratedFile(
path.relative(dir, result.savedFiles[0]).replace(/\\/g, '/'),
dir, idx, item.keyword, ''
)
}
} else {
throw new Error(`不支持的模型: ${model}(支持: gemini, mj`)
throw new Error(`不支持的模型: ${model}(支持: gemini, mj, kling`)
}
if (item.file) {
@@ -254,6 +268,13 @@ async function phaseImages(manifest, manifestPath, options) {
mjOpts.styleWeight = 200
}
lastResult = await mjGen(item.lastFramePrompt, mjOpts)
} else if (model === 'kling') {
const { generate: klingGen } = require('./kling-image-generator')
lastResult = await klingGen(item.lastFramePrompt, {
outputDir: imagesDir,
referenceImageUrl: item.url || '',
aspectRatio: ratio,
})
}
if (lastResult) {