diff --git a/.claude/skills/video-from-script/SKILL.md b/.claude/skills/video-from-script/SKILL.md index 494528a..d62a10f 100644 --- a/.claude/skills/video-from-script/SKILL.md +++ b/.claude/skills/video-from-script/SKILL.md @@ -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 --phase images --retry-failed --image-model <新模型>` Step 4: 上传 + 生视频(可选,图文成片跳过此步) 跑 upload + videos 阶段。首尾帧模式检查过渡连贯性。 diff --git a/.claude/skills/video-from-script/scripts/kling-image-generator.js b/.claude/skills/video-from-script/scripts/kling-image-generator.js new file mode 100644 index 0000000..f89637c --- /dev/null +++ b/.claude/skills/video-from-script/scripts/kling-image-generator.js @@ -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 [options] + +选项: + -o, --output 输出目录 (默认: ./output) + -r, --ref 参考图 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) + }) +} diff --git a/.claude/skills/video-from-script/scripts/pipeline.js b/.claude/skills/video-from-script/scripts/pipeline.js index 03e66a1..c23efdd 100644 --- a/.claude/skills/video-from-script/scripts/pipeline.js +++ b/.claude/skills/video-from-script/scripts/pipeline.js @@ -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 降级为 Gemini(MJ 出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) {