Files
video-create/.claude/skills/video-from-script/scripts/lib/phase-images.js
sion123 86b9b7948d feat(video-from-script): 重构工作流为子Agent分步执行并新增提示词模板系统
将视频制作工作流拆分为独立子步骤:分镜 → 图片提示词 → 生图 → 视频提示词 → 生视频 → 成片,每步由子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权限配置
2026-04-30 21:18:31 +08:00

174 lines
6.8 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 / 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 降级为 GeminiMJ 出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 }