将视频制作工作流拆分为独立子步骤:分镜 → 图片提示词 → 生图 → 视频提示词 → 生视频 → 成片,每步由子Agent独立执行。引入prompts/目录统一管理提示词模板(分镜.md、图片提示词.md、视频提示词.md),通过account.json的storyboardPrompt/imageStylePrompt/videoStylePrompt字段引用。 变更内容: - 新增confirmed机制和pipeline.js confirm命令,生图后必须人工确认才能继续 - manifest schema改用shotDesc/narration/duration/directorRef替代旧字段 - 文件命名规则从keyword改为slug(从shotDesc/narration派生) - 删除旧的storyboard-rules.md和prompt-rules.md - pipeline.js脚本拆分为lib/目录下的独立模块(cmd-init/cmd-confirm/cmd-validate/phase-*) - 新增cmd-create-account支持一键创建带prompts目录的账号 - capcut_assemble支持narration字段替代text作为字幕源 - 新增.gitclaude/settings.json权限配置
174 lines
6.8 KiB
JavaScript
174 lines
6.8 KiB
JavaScript
/**
|
||
* Phase: images — 图片生成
|
||
*
|
||
* 支持 Gemini / MJ / Kling 三种模型,含首尾帧模式
|
||
*/
|
||
|
||
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
|
||
)
|
||
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(MJ 出4张候选图无法一一对应首尾帧)
|
||
if (model === 'mj' && manifest.mode === 'framePair') {
|
||
log('images', '首尾帧模式不支持 MJ,自动降级为 Gemini')
|
||
model = 'gemini'
|
||
}
|
||
const refs = getReferences(manifest, accountConfig)
|
||
|
||
log('images', `共 ${items.length} 张, 模型: ${model}, 画幅: ${ratio}, 参考图: ${refs.localPaths.length}本地/${refs.urls.length}URL`)
|
||
|
||
for (let i = 0; i < items.length; i++) {
|
||
const item = items[i]
|
||
const idx = i + 1
|
||
try {
|
||
item.status = 'generating'
|
||
saveManifest(manifestPath, manifest)
|
||
|
||
let result
|
||
if (model === 'gemini') {
|
||
const { generate: geminiGen, edit: geminiEdit } = require('../gemini-image-generator')
|
||
if (refs.localPaths.length > 0) {
|
||
log('images', `[${idx}/${items.length}] Gemini 图生图: ${item.imagePrompt.substring(0, 60)}...`)
|
||
result = await geminiEdit(item.imagePrompt, refs.localPaths, {
|
||
outputDir: imagesDir,
|
||
aspectRatio: ratio,
|
||
})
|
||
} else {
|
||
log('images', `[${idx}/${items.length}] Gemini 文生图: ${item.imagePrompt.substring(0, 60)}...`)
|
||
result = await geminiGen(item.imagePrompt, {
|
||
outputDir: imagesDir,
|
||
aspectRatio: ratio,
|
||
})
|
||
}
|
||
if (result.savedFiles && result.savedFiles.length > 0) {
|
||
item.file = renameGeneratedFile(
|
||
path.relative(dir, result.savedFiles[0]).replace(/\\/g, '/'),
|
||
dir, idx, item.narration || item.shotDesc, ''
|
||
)
|
||
}
|
||
} else if (model === 'mj') {
|
||
const { generate: mjGen } = require('../mj-image-generator')
|
||
const mjOpts = { outputDir: imagesDir, aspectRatio: ratio, split: true }
|
||
if (refs.urls.length > 0) {
|
||
mjOpts.referenceImages = refs.urls
|
||
mjOpts.styleWeight = 200
|
||
}
|
||
log('images', `[${idx}/${items.length}] MJ 生图: ${item.imagePrompt.substring(0, 60)}...`)
|
||
result = await mjGen(item.imagePrompt, mjOpts)
|
||
if (result.files && result.files.length > 0) {
|
||
item.candidates = result.files.map((f, ci) =>
|
||
renameGeneratedFile(
|
||
path.relative(dir, f).replace(/\\/g, '/'),
|
||
dir, idx, item.narration || item.shotDesc, `cand${ci + 1}`
|
||
)
|
||
)
|
||
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.styleImageUrl = 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.narration || item.shotDesc, ''
|
||
)
|
||
}
|
||
} else {
|
||
throw new Error(`不支持的模型: ${model}(支持: gemini, mj, kling)`)
|
||
}
|
||
|
||
if (item.file) {
|
||
item.status = 'done'
|
||
log('images', `[${idx}/${items.length}] 完成: ${item.file}`)
|
||
} else {
|
||
item.status = 'failed'
|
||
item.error = '生成器未返回文件'
|
||
log('images', `[${idx}/${items.length}] 失败: 生成器未返回文件`)
|
||
}
|
||
|
||
// 首尾帧模式:生成第二张图(lastFrame)
|
||
if (item.status === 'done' && manifest.mode === 'framePair' && item.lastFramePrompt && !item.lastFrame) {
|
||
await generateLastFrame(item, idx, items.length, manifest, dir, imagesDir, model, ratio, manifestPath)
|
||
}
|
||
} catch (err) {
|
||
item.status = 'failed'
|
||
item.error = err.message
|
||
log('images', `[${idx}/${items.length}] 失败: ${err.message}`)
|
||
}
|
||
saveManifest(manifestPath, manifest)
|
||
}
|
||
}
|
||
|
||
async function generateLastFrame(item, idx, total, 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 === 'mj') {
|
||
const { generate: mjGen } = require('../mj-image-generator')
|
||
const mjOpts = { outputDir: imagesDir, aspectRatio: ratio, split: false }
|
||
if (item.url) {
|
||
mjOpts.referenceImages = [item.url]
|
||
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,
|
||
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.narration || item.shotDesc, 'last'
|
||
)
|
||
item.status = 'done'
|
||
log('images', `[${idx}/${total}] lastFrame 完成: ${item.lastFrame}`)
|
||
} else {
|
||
item.status = 'failed'
|
||
item.error = 'lastFrame 生成器未返回文件'
|
||
log('images', `[${idx}/${total}] lastFrame 失败: 未返回文件`)
|
||
}
|
||
}
|
||
} catch (err) {
|
||
item.status = 'failed'
|
||
item.error = `lastFrame 失败: ${err.message}`
|
||
log('images', `[${idx}/${total}] lastFrame 失败: ${err.message}`)
|
||
}
|
||
}
|
||
|
||
module.exports = { phaseImages }
|