Files
video-create/.claude/skills/video-from-script/scripts/lib/phase-images.js
sion123 0e3f0f7d0f feat(video-pipeline): 增强参考图自动上传与视频生成重试机制
- 在 `init-manifest` 阶段添加输入文件清理日志和 WARNING 提示
- `getReferences` 改为异步并自动将本地参考图上传至 OSS,减少手动操作
- `phase-videos` 支持 `pending`/`failed` 状态 item 的自动重试,自动清理旧视频引用
- 优化 `phase-assemble` 中字幕与配音开关的逻辑,根据实际内容动态判断
2026-05-03 02:03:17 +08:00

308 lines
12 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')
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 = 200
// 已有 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 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}`)
}
}
// ============================================================================
// 调度策略
// ============================================================================
/**
* 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 === '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}] 失败: 生成器未返回文件`)
}
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 }