Files
video-create/.claude/skills/video-from-script/scripts/lib/phase-images.js
sion123 35488beef2 feat(skills): 集成 GPT Image 图片生成和编辑能力
- 新增 gpt-image-generator.js 脚本,支持文生图、图生图/重绘、批量生成
- 更新 pipeline 和 phase-images 支持 GPT Image 模型
- 更新技能文档,添加 GPT Image 使用说明和 API 特点
- 新增配置文件中的 GPT Image API 参数
2026-05-05 23:49:30 +08:00

342 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.
/**
* Phase: images — 图片生成
*
* 支持 Gemini / GPT Image / MJ / Kling 四种模型,含首尾帧模式
* 并发生成,支持 task ID 恢复MJ
*/
const fs = require('fs')
const path = require('path')
const { saveManifest, getReferences, ensureDir, renameGeneratedFile, log, getManifestDir } = require('./pipeline-utils')
async function phaseImages(manifest, manifestPath, options) {
const dir = getManifestDir(manifestPath)
const imagesDir = path.join(dir, 'images')
ensureDir(imagesDir)
const items = manifest.items.filter(it =>
((!it.status || it.status === 'pending' || it.status === 'generating') && it.imagePrompt) ||
(it.status === 'done' && manifest.mode === 'framePair' && it.file && it.lastFramePrompt && !it.lastFrame)
)
if (items.length === 0) { log('images', '无待处理 item跳过'); return }
const accountConfig = options.accountConfig || {}
let model = options.imageModel || manifest.imageModel || accountConfig.imageModel || 'gemini'
const ratio = manifest.format || accountConfig.defaultFormat || '9:16'
// 首尾帧模式MJ 降级为 Gemini
if (model === 'mj' && manifest.mode === 'framePair') {
log('images', '首尾帧模式不支持 MJ自动降级为 Gemini')
model = 'gemini'
}
const refs = await getReferences(manifest, accountConfig)
log('images', `${items.length} 张, 模型: ${model}, 画幅: ${ratio}, 参考图: ${refs.localPaths.length}本地/${refs.urls.length}URL, 并发: 全并行`)
if (model === 'mj') {
// MJ 两阶段策略:先全部提交拿 taskId再滑动窗口收割
await phaseImagesMJ(items, manifest, manifestPath, dir, imagesDir, model, ratio, refs)
} else {
// Gemini/Kling滑动窗口并发
await phaseImagesSlidingWindow(items, manifest, manifestPath, dir, imagesDir, model, ratio, refs)
}
}
// ============================================================================
// 各模型生成逻辑
// ============================================================================
async function generateGemini(item, idx, dir, imagesDir, ratio, refs) {
const { generate: geminiGen, edit: geminiEdit } = require('../gemini-image-generator')
let result
if (refs.localPaths.length > 0) {
log('images', `[${idx}] Gemini 图生图: ${item.imagePrompt.substring(0, 60)}...`)
result = await geminiEdit(item.imagePrompt, refs.localPaths, {
outputDir: imagesDir,
aspectRatio: ratio,
})
} else {
log('images', `[${idx}] Gemini 文生图: ${item.imagePrompt.substring(0, 60)}...`)
result = await geminiGen(item.imagePrompt, {
outputDir: imagesDir,
aspectRatio: ratio,
})
}
const file = (result.savedFiles && result.savedFiles.length > 0)
? renameGeneratedFile(
path.relative(dir, result.savedFiles[0]).replace(/\\/g, '/'),
dir, idx, item.script || item.shotDesc, ''
)
: null
return { file }
}
/**
* MJ 提交阶段:仅提交任务,返回 taskId不轮询
* 用于与滑动窗口配合,先批量提交再并行轮询
*/
async function submitMJ(item, idx, dir, imagesDir, ratio, refs, manifestPath, manifest) {
const { MJApi } = require('../mj-image-generator')
const referenceImages = refs.urls.length > 0 ? refs.urls : []
const styleWeight = 100
// 已有 taskId 的跳过提交(恢复场景)
if (item.taskId && item.status === 'generating') {
log('images', `[${idx}] MJ 跳过提交,已有 taskId: ${item.taskId}`)
return item.taskId
}
log('images', `[${idx}] MJ 提交: ${item.imagePrompt.substring(0, 60)}...`)
const taskId = await MJApi.submit(item.imagePrompt, { referenceImages, aspectRatio: ratio, styleWeight })
item.taskId = taskId
saveManifest(manifestPath, manifest)
return taskId
}
/**
* MJ 收割阶段:轮询 + 下载 + 拆分 + 重命名
*/
async function harvestMJ(item, idx, dir, imagesDir, ratio, refs, manifestPath, manifest) {
const { MJApi, ImageUtils } = require('../mj-image-generator')
const pollResult = await MJApi.poll(item.taskId)
const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
const gridFile = path.join(imagesDir, `${timestamp}_grid.png`)
await ImageUtils.download(pollResult.imageUrl, gridFile)
const splitFiles = await ImageUtils.split4(gridFile, imagesDir, timestamp)
fs.unlinkSync(gridFile)
const result = { files: splitFiles }
const file = (result.files && result.files.length > 0) ? result.files[0] : null
const candidates = (result.files && result.files.length > 0)
? result.files.map((f, ci) =>
renameGeneratedFile(
path.relative(dir, f).replace(/\\/g, '/'),
dir, idx, item.script || item.shotDesc, `cand${ci + 1}`
)
)
: null
delete item.taskId
if (candidates && candidates.length > 0) {
log('images', `[${idx}] ${candidates.length} 张候选默认选第1张`)
return { file: candidates[0], candidates }
}
return { file }
}
async function generateMJ(item, idx, dir, imagesDir, ratio, refs, manifestPath, manifest) {
await submitMJ(item, idx, dir, imagesDir, ratio, refs, manifestPath, manifest)
return harvestMJ(item, idx, dir, imagesDir, ratio, refs, manifestPath, manifest)
}
async function generateGptImage(item, idx, dir, imagesDir, ratio, refs) {
const { generate: gptGen, edit: gptEdit, ratioToSize } = require('../gpt-image-generator')
const size = ratioToSize(ratio)
let result
if (refs.localPaths.length > 0) {
log('images', `[${idx}] GPT Image 图生图: ${item.imagePrompt.substring(0, 60)}...`)
result = await gptEdit(item.imagePrompt, refs.localPaths, {
outputDir: imagesDir,
size,
})
} else {
log('images', `[${idx}] GPT Image 文生图: ${item.imagePrompt.substring(0, 60)}...`)
result = await gptGen(item.imagePrompt, {
outputDir: imagesDir, size,
quality: 'auto',
})
}
const file = (result.savedFiles && result.savedFiles.length > 0)
? renameGeneratedFile(
path.relative(dir, result.savedFiles[0]).replace(/\\/g, '/'),
dir, idx, item.script || item.shotDesc, ''
)
: null
return { file }
}
async function generateKling(item, idx, dir, imagesDir, ratio, refs) {
const { generate: klingGen } = require('../kling-image-generator')
const klingOpts = { outputDir: imagesDir, aspectRatio: ratio }
if (refs.urls.length > 0) klingOpts.styleImageUrl = refs.urls[0]
log('images', `[${idx}] 可灵生图: ${item.imagePrompt.substring(0, 60)}...`)
const result = await klingGen(item.imagePrompt, klingOpts)
const file = (result.savedFiles && result.savedFiles.length > 0)
? renameGeneratedFile(
path.relative(dir, result.savedFiles[0]).replace(/\\/g, '/'),
dir, idx, item.script || item.shotDesc, ''
)
: null
return { file }
}
async function generateLastFrame(item, idx, manifest, dir, imagesDir, model, ratio, manifestPath) {
try {
item.status = 'generating'
saveManifest(manifestPath, manifest)
const firstFramePath = path.resolve(dir, item.file)
let lastResult
if (model === 'gemini') {
const { edit: geminiEdit } = require('../gemini-image-generator')
lastResult = await geminiEdit(item.lastFramePrompt, [firstFramePath], {
outputDir: imagesDir,
aspectRatio: ratio,
})
} else if (model === 'gpt-image') {
const { edit: gptEdit, ratioToSize } = require('../gpt-image-generator')
lastResult = await gptEdit(item.lastFramePrompt, [firstFramePath], {
outputDir: imagesDir,
size: ratioToSize(ratio),
})
} else if (model === 'kling') {
const { generate: klingGen } = require('../kling-image-generator')
lastResult = await klingGen(item.lastFramePrompt, {
outputDir: imagesDir,
styleImageUrl: item.url || '',
aspectRatio: ratio,
})
}
if (lastResult) {
const files = lastResult.savedFiles || lastResult.files || []
if (files.length > 0) {
item.lastFrame = renameGeneratedFile(
path.relative(dir, files[0]).replace(/\\/g, '/'),
dir, idx, item.script || item.shotDesc, 'last'
)
item.status = 'done'
log('images', `[${idx}] lastFrame 完成: ${item.lastFrame}`)
} else {
item.status = 'failed'
item.error = 'lastFrame 生成器未返回文件'
log('images', `[${idx}] lastFrame 失败: 未返回文件`)
}
}
} catch (err) {
item.status = 'failed'
item.error = `lastFrame 失败: ${err.message}`
log('images', `[${idx}] lastFrame 失败: ${err.message}`)
}
}
// ============================================================================
// 调度策略
// ============================================================================
/**
* Gemini/Kling全部并行
*/
async function phaseImagesSlidingWindow(items, manifest, manifestPath, dir, imagesDir, model, ratio, refs) {
await Promise.allSettled(items.map(item =>
processItem(item, manifest, manifestPath, dir, imagesDir, model, ratio, refs)
))
}
/**
* MJ 两阶段策略:
* 1. 先串行提交所有任务拿 taskIdMJ API 限制同时只能提交一个,但提交很快)
* 2. 滑动窗口收割:轮询+下载+拆分,完成一个立即补一个
*/
async function phaseImagesMJ(items, manifest, manifestPath, dir, imagesDir, model, ratio, refs) {
// 阶段1全部提交
log('images', `=== MJ 阶段1: 提交 ${items.length} 个任务 ===`)
for (const item of items) {
const idx = item.id
try {
item.status = 'generating'
saveManifest(manifestPath, manifest)
await submitMJ(item, idx, dir, imagesDir, ratio, refs, manifestPath, manifest)
} catch (err) {
item.status = 'failed'
item.error = err.message
log('images', `[${idx}] MJ 提交失败: ${err.message}`)
saveManifest(manifestPath, manifest)
}
}
// 阶段2全部并行收割MJ 轮询是轻量 HTTP不受本地并发限制
const harvestItems = items.filter(it => it.taskId && it.status === 'generating')
log('images', `=== MJ 阶段2: 并行收割 ${harvestItems.length} 个任务 ===`)
await Promise.allSettled(harvestItems.map(async (item) => {
const idx = item.id
try {
const result = await harvestMJ(item, idx, dir, imagesDir, ratio, refs, manifestPath, manifest)
if (result.file) {
item.file = result.file
if (result.candidates) item.candidates = result.candidates
item.status = 'done'
log('images', `[${idx}] 完成: ${item.file}`)
} else {
item.status = 'failed'
item.error = '生成器未返回文件'
}
saveManifest(manifestPath, manifest)
if (item.status === 'done' && manifest.mode === 'framePair' && item.lastFramePrompt && !item.lastFrame) {
await generateLastFrame(item, idx, manifest, dir, imagesDir, model, ratio, manifestPath)
}
} catch (err) {
item.status = 'failed'
item.error = err.message
log('images', `[${idx}] 收割失败: ${err.message}`)
saveManifest(manifestPath, manifest)
}
}))
}
/**
* 通用 item 处理Gemini/Kling 用)
*/
async function processItem(item, manifest, manifestPath, dir, imagesDir, model, ratio, refs) {
const idx = item.id
try {
item.status = 'generating'
saveManifest(manifestPath, manifest)
if (item.file && manifest.mode === 'framePair' && item.lastFramePrompt && !item.lastFrame) {
log('images', `[${idx}] 补生成 lastFrame首帧已有: ${item.file}`)
await generateLastFrame(item, idx, manifest, dir, imagesDir, model, ratio, manifestPath)
saveManifest(manifestPath, manifest)
return { ok: true }
}
let result
if (model === 'gemini') {
result = await generateGemini(item, idx, dir, imagesDir, ratio, refs)
} else if (model === 'gpt-image') {
result = await generateGptImage(item, idx, dir, imagesDir, ratio, refs)
} else if (model === 'kling') {
result = await generateKling(item, idx, dir, imagesDir, ratio, refs)
} else {
throw new Error(`不支持的模型: ${model}(支持: gemini, gpt-image, mj, kling`)
}
if (result.file) {
item.file = result.file
if (result.candidates) item.candidates = result.candidates
item.status = 'done'
log('images', `[${idx}] 完成: ${item.file}`)
} else {
item.status = 'failed'
item.error = '生成器未返回文件'
log('images', `[${idx}] 失败: 生成器未返回文件`)
}
saveManifest(manifestPath, manifest)
if (item.status === 'done' && manifest.mode === 'framePair' && item.lastFramePrompt && !item.lastFrame) {
await generateLastFrame(item, idx, manifest, dir, imagesDir, model, ratio, manifestPath)
}
return { ok: true }
} catch (err) {
item.status = 'failed'
item.error = err.message
log('images', `[${idx}] 失败: ${err.message}`)
saveManifest(manifestPath, manifest)
return { ok: false, error: err.message }
}
}
module.exports = { phaseImages }