Files
video-create/.claude/skills/video-from-script/scripts/mj-image-generator.js

439 lines
13 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
/**
* MJ Image Generator - Midjourney 图片生成工具
*
* 功能:
* - 提交 imagine 任务
* - 轮询直到完成
* - 下载 4 合 1 结果图
* - 自动拆分为 4 张独立图片
* - 支持参考图(图生图)
*
* 用法:
* node mj-image-generator.js "a cute cat" -o ./output
* node mj-image-generator.js "cyberpunk city" -o ./output -r http://example.com/ref.jpg
* node mj-image-generator.js batch ./prompts.txt -o ./output
*/
const fs = require('fs')
const path = require('path')
const https = require('https')
const http = require('http')
const sharp = require('sharp')
// ============================================================================
// 配置
// ============================================================================
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.mjApiBaseUrl,
apiKey: cfg.mjApiKey || '',
pollInterval: 5000, // 轮询间隔 5 秒
maxPollTime: 300000, // 最大等待 5 分钟
}
// ============================================================================
// API 调用
// ============================================================================
const MJApi = {
/**
* 提交 imagine 任务
*/
async submit(prompt, options = {}) {
const { referenceImages = [], botType = 'mj', aspectRatio = '', mjParams = '', styleWeight = 100 } = options
let finalPrompt = prompt
if (referenceImages.length > 0) {
const srefSection = `--sref ${referenceImages.join(' ')} --sw ${styleWeight}`
finalPrompt = `${prompt} ${srefSection}`.trim()
}
if (aspectRatio) {
finalPrompt = `${finalPrompt} --ar ${aspectRatio}`
}
if (mjParams) {
finalPrompt = `${finalPrompt} ${mjParams}`
}
const body = {
prompt: finalPrompt,
base64Array: [],
botType,
}
console.log(`\n📡 提交 MJ 任务`)
console.log(` 提示词: ${finalPrompt.substring(0, 80)}...`)
console.log(` 参考图: ${referenceImages.length}`)
const res = await fetch(`${Config.baseUrl}/mj/submit/imagine`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${Config.apiKey}`,
},
body: JSON.stringify(body),
})
const result = await res.json()
if (result.code !== 1 && result.code !== '1') {
throw new Error(`MJ 提交失败: code=${result.code}, ${result.description || JSON.stringify(result)}`)
}
const taskId = result.result
console.log(` 任务 ID: ${taskId}`)
return taskId
},
/**
* 轮询任务状态
*/
async poll(taskId) {
const startTime = Date.now()
let lastProgress = ''
console.log(`\n⏳ 等待 MJ 生成...`)
while (Date.now() - startTime < Config.maxPollTime) {
const res = await fetch(`${Config.baseUrl}/mj/task/${taskId}/fetch`, {
headers: { 'Authorization': `Bearer ${Config.apiKey}` },
})
const task = await res.json()
const status = task.status
if (status === 'SUCCESS') {
console.log(`\n✅ 生成完成!`)
console.log(` 图片 URL: ${task.imageUrl}`)
return {
success: true,
imageUrl: task.imageUrl,
prompt: task.prompt || task.promptEn,
}
}
if (status === 'FAILURE') {
const errMsg = task.failReason || '未知原因'
throw new Error(`MJ 生成失败: ${errMsg}`)
}
// 显示进度
const progress = task.progress || ''
if (progress !== lastProgress) {
lastProgress = progress
process.stdout.write(` 进度: ${progress}% 状态: ${status}\r`)
}
await new Promise(r => setTimeout(r, Config.pollInterval))
}
throw new Error(`MJ 生成超时 (${Config.maxPollTime / 1000}s)`)
},
}
// ============================================================================
// 图片处理
// ============================================================================
const ImageUtils = {
/**
* 下载图片到本地
*/
async 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 ImageUtils.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)
})
})
},
/**
* 将 4 合 1 图片拆分为 4 张独立图片
*/
async split4(gridImagePath, outputDir, prefix = 'mj') {
const image = sharp(gridImagePath)
const metadata = await image.metadata()
const { width, height } = metadata
// MJ 4 合 1 是 2x2 网格,每格约一半
const halfW = Math.floor(width / 2)
const halfH = Math.floor(height / 2)
const positions = [
{ name: `${prefix}_1`, x: 0, y: 0 }, // 左上
{ name: `${prefix}_2`, x: halfW, y: 0 }, // 右上
{ name: `${prefix}_3`, x: 0, y: halfH }, // 左下
{ name: `${prefix}_4`, x: halfW, y: halfH }, // 右下
]
const files = []
for (const pos of positions) {
const outputPath = path.join(outputDir, `${pos.name}.png`)
await sharp(gridImagePath)
.extract({ left: pos.x, top: pos.y, width: halfW, height: halfH })
.toFile(outputPath)
files.push(outputPath)
}
return files
},
}
// ============================================================================
// 核心流程
// ============================================================================
async function generate(prompt, options = {}) {
const {
outputDir = './output',
referenceImages = [],
aspectRatio = '',
mjParams = '',
split = true,
keepGrid = false,
} = options
if (!Config.apiKey) {
throw new Error('未配置 mjApiKey请在 config.json 中添加')
}
fs.mkdirSync(outputDir, { recursive: true })
// 1. 提交任务
const taskId = await MJApi.submit(prompt, { referenceImages, aspectRatio, mjParams, styleWeight: options.styleWeight })
// 2. 轮询等待
const result = await MJApi.poll(taskId)
// 3. 下载
const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
const gridFile = path.join(outputDir, `${timestamp}_grid.png`)
await ImageUtils.download(result.imageUrl, gridFile)
console.log(` 下载完成: ${gridFile}`)
// 4. 拆分
const allFiles = [gridFile]
if (split) {
const prefix = timestamp
const splitFiles = await ImageUtils.split4(gridFile, outputDir, prefix)
allFiles.push(...splitFiles)
console.log(` 拆分完成: ${splitFiles.length} 张图片`)
if (!keepGrid) {
fs.unlinkSync(gridFile)
allFiles.shift()
console.log(` 已删除网格图`)
}
}
return {
success: true,
taskId,
imageUrl: result.imageUrl,
files: allFiles,
}
}
/**
* 批量生成(并发提交 + 并行轮询)
*/
async function batchGenerate(prompts, options = {}) {
const concurrency = options.concurrency || prompts.length
const results = new Array(prompts.length).fill(null)
// 分批提交,每批 concurrency 个并行
for (let batchStart = 0; batchStart < prompts.length; batchStart += concurrency) {
const batchEnd = Math.min(batchStart + concurrency, prompts.length)
const batchIndices = []
for (let i = batchStart; i < batchEnd; i++) batchIndices.push(i)
// 并行提交所有任务
console.log(`\n📡 批量提交 [${batchStart + 1}-${batchEnd}/${prompts.length}]...`)
const taskIds = await Promise.all(batchIndices.map(async (i) => {
try {
const taskId = await MJApi.submit(prompts[i], {
referenceImages: options.referenceImages,
aspectRatio: options.aspectRatio,
mjParams: options.mjParams,
styleWeight: options.styleWeight,
})
return { i, taskId }
} catch (err) {
console.error(` [${i + 1}] ❌ 提交失败: ${err.message}`)
results[i] = { success: false, prompt: prompts[i], error: err.message }
return { i, taskId: null }
}
}))
// 过滤出成功的任务,并行轮询
const activeTasks = taskIds.filter(t => t.taskId)
console.log(` 已提交 ${activeTasks.length} 个任务,并行等待生成...\n`)
const pollResults = await Promise.all(activeTasks.map(async ({ i, taskId }) => {
try {
const result = await MJApi.poll(taskId)
// 下载 + 拆分
const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
const gridFile = path.join(options.outputDir || './output', `${timestamp}_grid.png`)
fs.mkdirSync(path.dirname(gridFile), { recursive: true })
await ImageUtils.download(result.imageUrl, gridFile)
const allFiles = [gridFile]
if (options.split !== false) {
const prefix = timestamp
const splitFiles = await ImageUtils.split4(gridFile, options.outputDir || './output', prefix)
allFiles.push(...splitFiles)
if (!options.keepGrid) {
fs.unlinkSync(gridFile)
allFiles.shift()
}
}
console.log(` [${i + 1}/${prompts.length}] ✅ 完成`)
return { i, success: true, prompt: prompts[i], taskId, imageUrl: result.imageUrl, files: allFiles }
} catch (err) {
console.error(` [${i + 1}/${prompts.length}] ❌ 失败: ${err.message}`)
return { i, success: false, prompt: prompts[i], error: err.message }
}
}))
for (const r of pollResults) results[r.i] = r
}
const ok = results.filter(r => r && r.success).length
console.log(`\n✨ 批量完成: ${ok}/${prompts.length} 成功`)
return results
}
// ============================================================================
// CLI
// ============================================================================
function showHelp() {
console.log(`
🎨 MJ Image Generator - Midjourney 图片生成工具
用法:
node mj-image-generator.js <prompt> [options]
node mj-image-generator.js batch <file> [options]
选项:
-o, --output <dir> 输出目录 (默认: ./output)
-r, --ref <urls> 参考图 URL逗号分隔
-a, --ar <ratio> 宽高比 (1:1, 16:9, 9:16, 3:4, 4:3 等)
-c, --concurrency <n> 并发数 (默认: 全部并行)
--no-split 不拆分 4 合 1
--keep-grid 保留原始网格图
-h, --help 帮助
示例:
node mj-image-generator.js "a cute cat" -o ./cats
node mj-image-generator.js "cyberpunk city" -a 16:9
node mj-image-generator.js "portrait" -r http://img.com/ref.jpg -a 9:16
node mj-image-generator.js batch ./prompts.txt -o ./batch
`)
}
async function main() {
const args = process.argv.slice(2)
if (args.includes('-h') || args.includes('--help') || args.length === 0) {
showHelp()
return
}
let command = 'generate'
let params = []
const options = { outputDir: './output', split: true, keepGrid: false, referenceImages: [], aspectRatio: '', concurrency: 0, mjParams: '', styleWeight: 100 }
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 === '-r' || arg === '--ref') {
options.referenceImages = args[++i].split(',').map(s => s.trim()).filter(Boolean)
} else if (arg === '--no-split') {
options.split = false
} else if (arg === '--keep-grid') {
options.keepGrid = true
} else if (arg === '-c' || arg === '--concurrency') {
options.concurrency = parseInt(args[++i], 10) || 0
} else if (arg === '--mj-params') {
options.mjParams = args[++i]
} else if (arg === '--sw') {
options.styleWeight = parseInt(args[++i], 10) || 100
} else {
params.push(arg)
}
i++
}
if (command === 'batch') {
const filePath = params[0]
if (!filePath || !fs.existsSync(filePath)) {
console.error('请提供提示词文件路径')
process.exit(1)
}
const prompts = fs.readFileSync(filePath, 'utf-8')
.split('\n').filter(l => l.trim()).map(l => l.trim())
await batchGenerate(prompts, options)
} else {
const prompt = params.join(' ')
if (!prompt) {
console.error('请提供提示词')
process.exit(1)
}
await generate(prompt, options)
}
}
// ============================================================================
// 导出
// ============================================================================
module.exports = { generate, batchGenerate, MJApi, ImageUtils }
if (require.main === module) {
main().catch(err => {
console.error(`\n❌ 错误: ${err.message}`)
process.exit(1)
})
}