init: video-create project with skills and accounts
This commit is contained in:
438
.claude/skills/video-from-script/scripts/mj-image-generator.js
Normal file
438
.claude/skills/video-from-script/scripts/mj-image-generator.js
Normal file
@@ -0,0 +1,438 @@
|
||||
#!/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)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user