Files
video-create/.claude/skills/video-from-script/scripts/lib/phase-images.js
sion123 0998fd6ae1 feat(video-pipeline): 重构视频流水线,优化成片时间线规则和状态管理
- 引入 manifest.json 作为唯一状态源,所有子 Agent 操作回写 manifest
- 重构 timebuilder 逻辑,支持四种视频适配策略(加速/裁剪/放缓/画面停顿)
- 统一 TTS 阶段输出结构,单句和多句均写入 segments[]
- 重写字幕和配音生成,基于 segments 精确时长实现音画同步
- 新增 confirm 命令支持按 id 范围确认,上传阶段分离图片和视频
- 添加中间产物写入 output/ 目录的约束,清理废弃配置参数
2026-05-02 00:14:40 +08:00

247 lines
9.4 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 三种模型,含首尾帧模式
* 并发生成,支持 task ID 恢复MJ
*/
const fs = require('fs')
const path = require('path')
const { saveManifest, getReferences, ensureDir, renameGeneratedFile, log, getManifestDir } = require('./pipeline-utils')
const IMAGE_CONCURRENCY = 3
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 = getReferences(manifest, accountConfig)
log('images', `${items.length} 张, 模型: ${model}, 画幅: ${ratio}, 参考图: ${refs.localPaths.length}本地/${refs.urls.length}URL, 并发: ${IMAGE_CONCURRENCY}`)
// 分批并发处理
for (let batchStart = 0; batchStart < items.length; batchStart += IMAGE_CONCURRENCY) {
const batch = items.slice(batchStart, batchStart + IMAGE_CONCURRENCY)
const results = await Promise.allSettled(
batch.map(async (item) => {
const idx = item.id
try {
item.status = 'generating'
saveManifest(manifestPath, manifest)
// 仅补 lastFrame首帧已存在跳过首帧生成
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 === 'mj') {
result = await generateMJ(item, idx, dir, imagesDir, ratio, refs, manifestPath, manifest)
} else if (model === 'kling') {
result = await generateKling(item, idx, dir, imagesDir, ratio, refs)
} else {
throw new Error(`不支持的模型: ${model}(支持: gemini, 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}] 失败: 生成器未返回文件`)
}
// 每个 item 完成后立即写盘,防止崩溃丢失已完成的结果
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 }
}
})
)
}
}
// ============================================================================
// 各模型生成逻辑
// ============================================================================
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 }
}
async function generateMJ(item, idx, dir, imagesDir, ratio, refs, manifestPath, manifest) {
const { MJApi, ImageUtils } = require('../mj-image-generator')
const referenceImages = refs.urls.length > 0 ? refs.urls : []
const styleWeight = 200
let result
// 尝试恢复中断的 MJ 任务
if (item.taskId && item.status === 'generating') {
try {
log('images', `[${idx}] 恢复 MJ 任务: ${item.taskId}`)
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)
result = { files: splitFiles }
log('images', `[${idx}] MJ 任务恢复成功`)
} catch (err) {
log('images', `[${idx}] MJ 任务恢复失败: ${err.message},重新提交`)
delete item.taskId
}
}
// 新提交
if (!result) {
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)
const pollResult = await MJApi.poll(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)
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 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 === '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}`)
}
}
module.exports = { phaseImages }