#!/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 [options] node mj-image-generator.js batch [options] 选项: -o, --output 输出目录 (默认: ./output) -r, --ref 参考图 URL,逗号分隔 -a, --ar 宽高比 (1:1, 16:9, 9:16, 3:4, 4:3 等) -c, --concurrency 并发数 (默认: 全部并行) --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) }) }