Files
video-create/.claude/skills/video-from-script/scripts/pipeline.js
2026-04-29 21:26:42 +08:00

1084 lines
38 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.
#!/usr/bin/env node
/**
* 统一视频生产流程编排器
*
* 串联: 生图 → 上传 → 生视频 → TTS → 成片
* manifest.json 是唯一数据源,支持断点续跑
*
* 用法:
* node pipeline.js run --manifest ./output/batch/manifest.json --account military --resume
* node pipeline.js run --manifest ./output/batch/manifest.json --phase upload,videos
* node pipeline.js status --manifest ./output/batch/manifest.json
*/
const fs = require('fs')
const path = require('path')
const SCRIPTS_DIR = __dirname
const SKILLS_DIR = path.join(SCRIPTS_DIR, '..')
const CONFIG_PATH = path.join(SKILLS_DIR, 'config.json')
const ACCOUNTS_DIR = path.join(SCRIPTS_DIR, '..', 'accounts')
// ============================================================================
// 工具函数
// ============================================================================
function loadConfig() {
return JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'))
}
function loadManifest(manifestPath) {
return JSON.parse(fs.readFileSync(manifestPath, 'utf-8'))
}
function saveManifest(manifestPath, manifest) {
const tmp = manifestPath + '.tmp'
fs.writeFileSync(tmp, JSON.stringify(manifest, null, 2), 'utf-8')
fs.renameSync(tmp, manifestPath)
}
function loadAccountConfig(accountId) {
const accountPath = path.join(ACCOUNTS_DIR, accountId, 'account.json')
if (!fs.existsSync(accountPath)) throw new Error(`账号不存在: ${accountPath}`)
return JSON.parse(fs.readFileSync(accountPath, 'utf-8'))
}
function getReferences(manifest, accountConfig) {
const result = { localPaths: [], urls: [] }
const accountId = accountConfig.id || manifest.account || ''
// 优先读 manifest.referencesagent 创建时写入)
const refs = manifest.references || []
if (refs.length > 0) {
for (const ref of refs) {
if (ref.url) result.urls.push(ref.url)
if (ref.file) {
const localPath = path.isAbsolute(ref.file) ? ref.file : path.resolve(ref.file)
if (fs.existsSync(localPath)) {
result.localPaths.push(localPath)
} else {
log('images', `参考图不存在: ${ref.file}`)
}
}
}
if (result.localPaths.length > 0 || result.urls.length > 0) return result
log('images', 'manifest.references 全部无效,尝试 account fallback')
}
// Fallback 1: 从 account.json 的 styles.*.references 读取
const styles = accountConfig.styles || {}
for (const [, style] of Object.entries(styles)) {
for (const ref of (style.references || [])) {
if (ref.url) result.urls.push(ref.url)
if (ref.file && accountId) {
const localPath = path.join(ACCOUNTS_DIR, accountId, 'references', ref.file)
if (fs.existsSync(localPath)) {
result.localPaths.push(localPath)
}
}
}
}
if (result.localPaths.length > 0 || result.urls.length > 0) return result
// Fallback 2: 扫描 account 的 references 目录
if (accountId) {
const refDir = path.join(ACCOUNTS_DIR, accountId, 'references')
if (fs.existsSync(refDir)) {
const files = fs.readdirSync(refDir).filter(f =>
/\.(png|jpg|jpeg|webp)$/i.test(f)
)
for (const f of files) {
result.localPaths.push(path.join(refDir, f))
}
if (files.length > 0) {
log('images', `从 references 目录兜底扫描到 ${files.length} 个参考图`)
}
}
}
if (result.localPaths.length === 0 && result.urls.length === 0) {
log('images', '无参考图,将使用纯文生图模式')
}
return result
}
function ensureDir(dir) {
fs.mkdirSync(dir, { recursive: true })
}
function slugify(text) {
return text
.replace(/[^\w一-鿿]/g, '_')
.replace(/_+/g, '_')
.replace(/^_|_$/g, '')
.substring(0, 20)
}
function renameGeneratedFile(oldRelPath, dir, seq, keyword, suffix) {
if (!oldRelPath) return oldRelPath
const oldAbs = path.resolve(dir, oldRelPath)
if (!fs.existsSync(oldAbs)) return oldRelPath
const ext = path.extname(oldAbs)
const slug = keyword ? slugify(keyword) : ''
const tag = suffix ? `_${suffix}` : ''
const newName = slug
? `scene_${String(seq).padStart(2, '0')}_${slug}${tag}${ext}`
: `scene_${String(seq).padStart(2, '0')}${tag}${ext}`
const newAbs = path.join(path.dirname(oldAbs), newName)
if (oldAbs !== newAbs) {
try { fs.renameSync(oldAbs, newAbs) } catch (_) { return oldRelPath }
}
return path.relative(dir, newAbs).replace(/\\/g, '/')
}
function log(phase, msg) {
console.log(`[${phase}] ${msg}`)
}
function getManifestDir(manifestPath) {
return path.dirname(path.resolve(manifestPath))
}
// ============================================================================
// 阶段: images
// ============================================================================
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 = 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.keyword, ''
)
}
} 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)
// MJ 拆分后4张候选全部存入 candidates默认取第一张
if (result.files && result.files.length > 0) {
item.candidates = result.files.map((f, ci) =>
renameGeneratedFile(
path.relative(dir, f).replace(/\\/g, '/'),
dir, idx, item.keyword, `cand${ci + 1}`
)
)
item.file = item.candidates[0]
log('images', `[${idx}/${items.length}] ${result.files.length} 张候选默认选第1张`)
}
} else {
throw new Error(`不支持的模型: ${model}(支持: gemini, mj`)
}
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) {
try {
item.status = 'generating'
saveManifest(manifestPath, manifest)
const firstFramePath = path.resolve(dir, item.file)
let lastResult
if (model === 'gemini') {
const { generate: geminiGen, 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')
// MJ: 尾帧上传首帧到 OSS 后用 URL 做 sref
const mjOpts = { outputDir: imagesDir, aspectRatio: ratio, split: false }
if (item.url) {
mjOpts.referenceImages = [item.url]
mjOpts.styleWeight = 200
}
lastResult = await mjGen(item.lastFramePrompt, mjOpts)
}
if (lastResult) {
const files = lastResult.savedFiles || lastResult.files || []
if (files.length > 0) {
item.lastFrame = renameGeneratedFile(
path.relative(dir, files[0]).split(String.fromCharCode(92)).join(String.fromCharCode(47)),
dir, idx, item.keyword, 'last'
)
item.status = 'done'
log('images', `[${idx}/${items.length}] lastFrame 完成: ${item.lastFrame}`)
} else {
item.status = 'failed'
item.error = 'lastFrame 生成器未返回文件'
log('images', `[${idx}/${items.length}] lastFrame 失败: 未返回文件`)
}
}
} catch (err) {
item.status = 'failed'
item.error = `lastFrame 失败: ${err.message}`
log('images', `[${idx}/${items.length}] lastFrame 失败: ${err.message}`)
}
}
} catch (err) {
item.status = 'failed'
item.error = err.message
log('images', `[${idx}/${items.length}] 失败: ${err.message}`)
}
saveManifest(manifestPath, manifest)
}
}
// ============================================================================
// 阶段: upload (OSS)
// ============================================================================
async function phaseUpload(manifest, manifestPath, options) {
const dir = getManifestDir(manifestPath)
const { uploadFile } = require('./oss-upload')
const items = manifest.items.filter(it =>
it.status === 'done' && it.file && !it.url
)
if (items.length === 0) { log('upload', '无待上传 item跳过'); return }
log('upload', `${items.length} 个文件`)
for (let i = 0; i < items.length; i++) {
const item = items[i]
const filePath = path.resolve(dir, item.file)
try {
const { url } = await uploadFile(filePath)
item.url = url
log('upload', `[${i + 1}/${items.length}] ${item.file}${url.substring(0, 60)}...`)
} catch (err) {
item.error = `上传失败: ${err.message}`
log('upload', `[${i + 1}/${items.length}] 失败: ${err.message}`)
}
// 首尾帧模式:上传 lastFrame
if (item.url && item.lastFrame && !item.lastFrameUrl) {
const lastPath = path.resolve(dir, item.lastFrame)
try {
const { url } = await uploadFile(lastPath)
item.lastFrameUrl = url
log('upload', `[${i + 1}/${items.length}] lastFrame → OK`)
} catch (err) {
log('upload', `[${i + 1}/${items.length}] lastFrame 上传失败: ${err.message}`)
}
}
saveManifest(manifestPath, manifest)
}
}
// ============================================================================
// 阶段: videos (VEO/Grok)
// ============================================================================
async function phaseVideos(manifest, manifestPath, options) {
const dir = getManifestDir(manifestPath)
const videosDir = path.join(dir, 'videos')
ensureDir(videosDir)
const accountConfig = options.accountConfig || {}
const videoModel = manifest.videoModel || accountConfig.videoModel || 'veo3-fast-frames'
const items = manifest.items.filter(it =>
it.status === 'done' && it.url && it.videoPrompt && !it.video
)
if (items.length === 0) { log('videos', '无待处理 item跳过'); return }
// 选择生成器
let generator
const modelLower = videoModel.toLowerCase()
if (modelLower.includes('grok')) {
generator = require('./grok-video-generator')
} else if (modelLower.includes('kling')) {
generator = require('./kling-video-generator')
} else {
generator = require('./veo-video-generator')
}
log('videos', `${items.length} 个, 模型: ${videoModel}`)
// 构建任务列表
const tasks = items.map((item, i) => {
const task = {
id: item.id || i + 1,
prompt: item.videoPrompt,
image: item.url,
outputDir: videosDir,
}
// 单图 or 首尾帧
if (item.lastFrameUrl) {
task.images = [item.url, item.lastFrameUrl]
task.lastFrameUrl = item.lastFrameUrl
} else {
task.images = [item.url]
}
return task
})
try {
const results = await generator.batchGenerate(tasks, {
videoModel,
aspectRatio: manifest.format || '9:16',
outputDir: videosDir,
skipManifestWrite: true,
})
// 将结果写回 manifestgenerators return results in same order as tasks
for (let i = 0; i < results.length; i++) {
const result = results[i]
const item = items[i]
if (!item) continue
if (result.success && result.file) {
item.video = path.relative(dir, result.file).replace(/\\/g, '/')
item.videoDuration = result.duration
} else {
item.status = 'failed'
item.error = result.error || '视频生成失败'
log('videos', ` item ${(item.id || '?')} 失败: ${item.error}`)
}
}
} catch (err) {
log('videos', `批量生成失败: ${err.message}`)
for (const item of items) {
if (!item.video) {
item.status = 'failed'
item.error = `批量生成异常: ${err.message}`
}
}
}
// 上传生成的视频到 OSS回写 videoUrl
const { uploadFile } = require('./oss-upload')
const videoItems = manifest.items.filter(it => it.video && !it.videoUrl)
if (videoItems.length > 0) {
log('videos', `上传 ${videoItems.length} 个视频到 OSS...`)
for (const item of videoItems) {
const videoPath = path.resolve(dir, item.video)
try {
const { url } = await uploadFile(videoPath)
item.videoUrl = url
log('videos', ` ${item.video} → OK`)
} catch (err) {
log('videos', ` ${item.video} 上传失败: ${err.message}`)
}
saveManifest(manifestPath, manifest)
}
}
saveManifest(manifestPath, manifest)
}
// ============================================================================
// 阶段: tts
// ============================================================================
async function phaseTts(manifest, manifestPath, options) {
const dir = getManifestDir(manifestPath)
const audioDir = path.join(dir, 'audio')
ensureDir(audioDir)
const { synthesize } = require('./qwen-tts')
const items = manifest.items.filter(it =>
it.status === 'done' && it.text && !it.audio
)
if (items.length === 0) { log('tts', '无待处理 item跳过'); return }
log('tts', `${items.length}`)
for (let i = 0; i < items.length; i++) {
const item = items[i]
const idx = i + 1
try {
const { filePath, duration } = await synthesize(item.text, {
outputDir: audioDir,
id: item.id || idx,
})
item.audio = path.relative(dir, filePath).replace(/\\/g, '/')
item.duration = Math.round(duration * 1000) / 1000
log('tts', `[${idx}/${items.length}] ${duration.toFixed(1)}s: ${item.text.substring(0, 30)}...`)
} catch (err) {
item.status = 'failed'
item.error = `TTS失败: ${err.message}`
log('tts', `[${idx}/${items.length}] 失败: ${err.message}`)
}
saveManifest(manifestPath, manifest)
}
}
// ============================================================================
// 阶段: assemble (CapCut)
// ============================================================================
async function phaseAssemble(manifest, manifestPath, options) {
const dir = getManifestDir(manifestPath)
const accountConfig = options.accountConfig || {}
const capcutConfig = accountConfig.capcut || {}
// 判断模式: 有 video 字段 → videos 模式(仅限成功的 item
const videoItems = manifest.items.filter(it => it.video && it.status === 'done')
const hasVideos = videoItems.length > 0
const mode = hasVideos ? 'videos' : 'images'
// 构造 capcut_assemble 参数
const assembleArgs = {
input: dir,
manifest: manifestPath,
mode,
format: manifest.format || accountConfig.defaultFormat || '9:16',
subtitles: mode === 'images' ? 'true' : 'false',
voiceover: manifest.items.some(it => it.audio) ? 'true' : 'false',
duration: '4',
animation: 'kenburns-zoom',
}
if (capcutConfig.defaultBGM) assembleArgs.bgm = capcutConfig.defaultBGM
if (capcutConfig.effects) assembleArgs.effects = capcutConfig.effects.join(',')
if (capcutConfig.filter) assembleArgs.filter = capcutConfig.filter
log('assemble', `模式: ${mode}, 字幕: true, 配音: ${assembleArgs.voiceover}`)
// 调用 capcut_assemble
const { assemble } = require('./capcut_assemble')
await assemble(assembleArgs)
log('assemble', '成片完成')
}
// ============================================================================
// Pipeline 编排
// ============================================================================
const ALL_PHASES = ['images', 'upload', 'videos', 'tts', 'assemble']
async function runPipeline(manifestPath, options) {
const manifest = loadManifest(manifestPath)
const dir = getManifestDir(manifestPath)
// 加载账号配置
const accountId = manifest.account || options.account
let accountConfig = {}
if (accountId) {
accountConfig = loadAccountConfig(accountId)
log('pipeline', `账号: ${accountConfig.name || accountId}`)
}
// 确定要跑的阶段
const phases = options.phases || ALL_PHASES
// 确保 pipeline 状态字段存在
if (!manifest.pipeline) {
manifest.pipeline = { phases: {} }
}
log('pipeline', `阶段: ${phases.join(' → ')}`)
const phaseHandlers = {
images: phaseImages,
upload: phaseUpload,
videos: phaseVideos,
tts: phaseTts,
assemble: phaseAssemble,
}
for (const phase of phases) {
const handler = phaseHandlers[phase]
if (!handler) { log('pipeline', `未知阶段: ${phase}`); continue }
// resume 模式下跳过已完成阶段
if (options.resume && manifest.pipeline.phases[phase] === 'done') {
log(phase, '已完成,跳过 (--resume)')
continue
}
manifest.pipeline.phases[phase] = 'running'
saveManifest(manifestPath, manifest)
try {
await handler(manifest, manifestPath, { ...options, accountConfig })
// 检查是否所有 item 都完成
const failedCount = manifest.items.filter(it => it.status === 'failed').length
if (failedCount > 0) {
manifest.pipeline.phases[phase] = 'partial'
log(phase, `完成 (${failedCount} 个失败)`)
} else {
manifest.pipeline.phases[phase] = 'done'
log(phase, '完成')
}
} catch (err) {
manifest.pipeline.phases[phase] = 'failed'
log(phase, `阶段失败: ${err.message}`)
saveManifest(manifestPath, manifest)
throw err
}
saveManifest(manifestPath, manifest)
}
log('pipeline', '全部完成')
}
// ============================================================================
// status 命令
// ============================================================================
function showStatus(manifestPath) {
const manifest = loadManifest(manifestPath)
const phases = manifest.pipeline?.phases || {}
console.log(`\nManifest: ${manifestPath}`)
console.log(`Account: ${manifest.account || '(未指定)'}`)
console.log(`\n阶段状态:`)
for (const p of ALL_PHASES) {
const status = phases[p] || 'pending'
const icon = status === 'done' ? '✓' : status === 'running' ? '→' : status === 'failed' ? '✗' : status === 'partial' ? '~' : '·'
console.log(` ${icon} ${p}: ${status}`)
}
const items = manifest.items || []
const done = items.filter(it => it.status === 'done').length
const failed = items.filter(it => it.status === 'failed').length
const pending = items.filter(it => !it.status || it.status === 'pending').length
console.log(`\nItems: ${items.length} 总计, ${done} 完成, ${failed} 失败, ${pending} 待处理`)
if (failed > 0) {
console.log(`\n失败项:`)
items.filter(it => it.status === 'failed').forEach((it, i) => {
console.log(` [${it.id || i + 1}] ${it.error || '未知错误'}`)
})
}
console.log()
}
// ============================================================================
// 命令: init — 从 account.json + AI 创意内容生成规范 manifest.json
// ============================================================================
function initManifest(options) {
const { account: accountId, mode, items: itemsJson, itemsFile } = options
if (!accountId) {
console.error('错误: 必须指定 --account <账号ID>')
process.exit(1)
}
// 1. 加载账号配置
const accountConfig = loadAccountConfig(accountId)
// 2. 解析 items
let rawItems
if (itemsFile) {
const filePath = path.resolve(itemsFile)
if (!fs.existsSync(filePath)) {
console.error(`错误: items 文件不存在: ${filePath}`)
process.exit(1)
}
rawItems = JSON.parse(fs.readFileSync(filePath, 'utf-8'))
} else if (itemsJson) {
rawItems = JSON.parse(itemsJson)
} else {
console.error('错误: 必须指定 --items <JSON> 或 --items-file <path>')
process.exit(1)
}
if (!Array.isArray(rawItems) || rawItems.length === 0) {
console.error('错误: items 必须是非空数组')
process.exit(1)
}
// 3. 校验 items 必填字段
const requiredFields = ['text', 'imagePrompt']
const resolvedMode = mode || 'single'
for (let i = 0; i < rawItems.length; i++) {
const item = rawItems[i]
for (const f of requiredFields) {
if (!item[f]) {
console.error(`错误: items[${i}] 缺少必填字段 "${f}"`)
process.exit(1)
}
}
// 首尾帧模式imagePrompt 是第一帧lastFramePrompt 是第二帧
if (resolvedMode === 'framePair') {
if (!item.lastFramePrompt) {
console.error(`错误: 首尾帧模式 items[${i}] 缺少 "lastFramePrompt"imagePrompt 作为第一帧)`)
process.exit(1)
}
}
}
// 4. 构建 manifest — 从 account.json 继承结构字段
const styles = accountConfig.styles || {}
const firstStyleKey = Object.keys(styles)[0]
const styleRefs = firstStyleKey ? (styles[firstStyleKey].references || []) : []
// 将 account references 转为 manifest references 格式
const references = styleRefs.map(ref => {
const entry = {}
if (ref.file) {
// 转为相对路径(相对于 manifest 所在目录)
entry.file = path.join(ACCOUNTS_DIR, accountId, 'references', ref.file)
}
if (ref.url) entry.url = ref.url
return entry
})
// 5. 构建 itemsAI 只提供创意字段,脚本补齐 status
const items = rawItems.map((raw, i) => {
const item = {
id: i + 1,
status: 'pending',
text: raw.text,
imagePrompt: raw.imagePrompt,
keyword: raw.keyword || '',
keywordColor: raw.keywordColor || accountConfig.capcut?.subtitleStyle?.highlightColor || '#FF6B35',
}
if (raw.videoPrompt) item.videoPrompt = raw.videoPrompt
if (resolvedMode === 'framePair') {
// imagePrompt 就是第一帧lastFramePrompt 是第二帧
item.lastFramePrompt = raw.lastFramePrompt
}
return item
})
// 6. 组装 manifest
const manifest = {
account: accountId,
imageModel: accountConfig.imageModel || 'gemini',
videoModel: accountConfig.videoModel || 'veo3-fast-frames',
format: accountConfig.defaultFormat || '9:16',
mode: resolvedMode,
references,
items,
}
// 7. 创建输出目录(自增序号)
const date = new Date()
const dateStr = [
date.getFullYear(),
String(date.getMonth() + 1).padStart(2, '0'),
String(date.getDate()).padStart(2, '0'),
].join('')
const prefix = `${accountId}_${dateStr}`
const outputBase = path.join(SKILLS_DIR, '..', '..', '..', 'output')
ensureDir(outputBase)
let seq = 1
while (fs.existsSync(path.join(outputBase, `${prefix}_${String(seq).padStart(3, '0')}`))) {
seq++
}
const dirName = `${prefix}_${String(seq).padStart(3, '0')}`
const outputDir = path.join(outputBase, dirName)
ensureDir(outputDir)
ensureDir(path.join(outputDir, 'images'))
ensureDir(path.join(outputDir, 'videos'))
ensureDir(path.join(outputDir, 'audio'))
// 8. 写出 manifest.json
const manifestPath = path.join(outputDir, 'manifest.json')
saveManifest(manifestPath, manifest)
console.log(`\nManifest 已创建: ${manifestPath}`)
console.log(` 账号: ${accountId} (${accountConfig.name})`)
console.log(` 模型: ${manifest.imageModel} + ${manifest.videoModel}`)
console.log(` 画幅: ${manifest.format}, 模式: ${manifest.mode}`)
console.log(` Items: ${items.length}`)
console.log(` 参考图: ${references.length}`)
if (items.some(it => !it.videoPrompt)) {
console.log(`${items.filter(it => !it.videoPrompt).length} 个 item 缺少 videoPrompt生视频阶段将跳过`)
}
console.log()
return manifestPath
}
// ============================================================================
// 命令: validate — 校验已有 manifest.json 的完整性
// ============================================================================
function validateManifest(manifestPath) {
const issues = []
if (!fs.existsSync(manifestPath)) {
console.error(`错误: manifest 不存在: ${manifestPath}`)
process.exit(1)
}
let manifest
try {
manifest = loadManifest(manifestPath)
} catch (e) {
console.error(`错误: JSON 解析失败: ${e.message}`)
process.exit(1)
}
// 顶层必填
if (!manifest.account) issues.push('缺少顶层 account')
if (!manifest.imageModel) issues.push('缺少顶层 imageModel可选: gemini, mj')
if (!manifest.format) issues.push('缺少顶层 format如 9:16')
if (!manifest.items || !Array.isArray(manifest.items)) issues.push('缺少顶层 items 数组')
if (!manifest.mode) issues.push('缺少顶层 modesingle 或 framePair')
// items 校验
if (manifest.items && Array.isArray(manifest.items)) {
manifest.items.forEach((item, i) => {
const prefix = `items[${i}]`
if (!item.text) issues.push(`${prefix} 缺少 text中文字幕`)
if (!item.imagePrompt) issues.push(`${prefix} 缺少 imagePrompt`)
if (manifest.mode === 'framePair') {
if (!item.lastFramePrompt) issues.push(`${prefix} 首尾帧模式缺少 lastFramePromptimagePrompt 作为第一帧)`)
}
if (item.status && !['pending', 'generating', 'done', 'failed'].includes(item.status)) {
issues.push(`${prefix} status 无效: ${item.status}`)
}
})
}
// 输出结果
if (issues.length === 0) {
console.log(`✓ Manifest 校验通过: ${manifestPath}`)
console.log(` ${manifest.items?.length || 0} items, account=${manifest.account}, mode=${manifest.mode}`)
} else {
console.error(`✗ 发现 ${issues.length} 个问题:`)
issues.forEach(issue => console.error(` - ${issue}`))
process.exit(1)
}
}
// ============================================================================
// 命令: create-account — 一键创建账号
// ============================================================================
async function createAccount(args) {
const { id, name, desc, format, imageModel, videoModel, references } = args
if (!id) { console.error('错误: 必须指定 --id <账号ID>'); process.exit(1) }
if (!/^[a-z0-9_-]+$/.test(id)) { console.error('错误: id 只允许小写字母/数字/短横线/下划线'); process.exit(1) }
if (!name) { console.error('错误: 必须指定 --name <账号名>'); process.exit(1) }
const accountDir = path.join(ACCOUNTS_DIR, id)
if (fs.existsSync(accountDir)) { console.error(`错误: 账号已存在: ${accountDir}`); process.exit(1) }
ensureDir(accountDir)
ensureDir(path.join(accountDir, 'references'))
ensureDir(path.join(accountDir, 'styles'))
// 复制参考图到 references/ 并上传 OSS
const refs = (references || '').split(',').filter(Boolean)
const uploadedRefs = []
if (refs.length > 0) {
const { uploadFile } = require('./oss-upload')
for (const refPath of refs) {
const absPath = path.resolve(refPath)
if (!fs.existsSync(absPath)) { console.error(`参考图不存在: ${absPath}`); continue }
const fileName = path.basename(absPath)
const destPath = path.join(accountDir, 'references', fileName)
fs.copyFileSync(absPath, destPath)
try {
const { url } = await uploadFile(destPath)
uploadedRefs.push({ file: fileName, url })
log('account', `参考图 ${fileName} → OK`)
} catch (err) {
uploadedRefs.push({ file: fileName })
log('account', `参考图 ${fileName} 上传失败: ${err.message}(仅保存本地)`)
}
}
}
// 生成 account.json
const styleName = args.style || id
const accountConfig = {
id,
name,
description: desc || '',
defaultFormat: format || '9:16',
imageModel: imageModel || 'gemini',
videoModel: videoModel || '',
batchSize: 30,
capcut: {
effects: [],
filter: '',
subtitleStyle: {
fontSize: 36,
color: '#FFFFFF',
highlightColor: '#FF6B35',
bold: true,
},
defaultBGM: '',
},
}
if (uploadedRefs.length > 0) {
accountConfig.styles = {
[styleName]: { references: uploadedRefs },
}
}
const accountPath = path.join(accountDir, 'account.json')
fs.writeFileSync(accountPath, JSON.stringify(accountConfig, null, 2), 'utf-8')
// 生成默认风格骨架
const stylePath = path.join(accountDir, 'styles', `${styleName}.md`)
const styleContent = [
`# ${styleName}`,
'',
`${desc || name} 的视觉风格。`,
'',
'---',
'',
'## 图片提示词',
'',
'### 核心视觉要素',
'',
'(待填充:描述关键视觉元素)',
'',
'### 色调方案',
'',
'(待填充)',
'',
'### 图片 Prompt 模板',
'',
'(待填充)',
'',
'### 图片禁止项',
'',
'- 文字水印',
'- 字幕覆盖',
'',
'---',
'',
'## 视频提示词',
'',
'### 运镜规则',
'',
'(待填充)',
'',
'### 视频 Prompt 模板',
'',
'(待填充)',
'',
].join('\n')
fs.writeFileSync(stylePath, styleContent, 'utf-8')
console.log(`\n账号已创建: ${accountDir}`)
console.log(` ID: ${id}`)
console.log(` 名称: ${name}`)
console.log(` 模型: ${accountConfig.imageModel} + ${accountConfig.videoModel || '(未指定)'}`)
console.log(` 参考图: ${uploadedRefs.length} 张(${uploadedRefs.filter(r => r.url).length} 已上传)`)
console.log(` 风格: ${styleName}`)
console.log(`\n下一步: 编辑 ${stylePath} 完善提示词策略\n`)
return accountPath
}
// ============================================================================
// 命令: validate-account — 校验账号完整性
// ============================================================================
function validateAccount(accountId) {
const issues = []
const accountDir = path.join(ACCOUNTS_DIR, accountId)
if (!fs.existsSync(accountDir)) { console.error(`错误: 账号不存在: ${accountDir}`); process.exit(1) }
const accountPath = path.join(accountDir, 'account.json')
if (!fs.existsSync(accountPath)) { console.error('错误: 缺少 account.json'); process.exit(1) }
let config
try { config = JSON.parse(fs.readFileSync(accountPath, 'utf-8')) }
catch (e) { console.error(`错误: JSON 解析失败: ${e.message}`); process.exit(1) }
if (config.id !== accountId) issues.push(`id 不匹配: json="${config.id}" vs 目录="${accountId}"`)
if (!config.name) issues.push('缺少 name')
if (!config.imageModel) issues.push('缺少 imageModel')
if (!config.defaultFormat) issues.push('缺少 defaultFormat')
// 参考图检查
const refDir = path.join(accountDir, 'references')
const styles = config.styles || {}
const hasStyleRefs = Object.values(styles).some(s => s.references && s.references.length > 0)
const localRefs = fs.existsSync(refDir)
? fs.readdirSync(refDir).filter(f => /\.(png|jpg|jpeg|webp)$/i.test(f))
: []
if (localRefs.length === 0 && !hasStyleRefs) {
issues.push('无参考图(建议至少 1 张)')
}
// 风格文件检查
const stylesDir = path.join(accountDir, 'styles')
const styleFiles = fs.existsSync(stylesDir)
? fs.readdirSync(stylesDir).filter(f => f.endsWith('.md'))
: []
if (styleFiles.length === 0) {
issues.push('无风格文件styles/ 下至少 1 个 .md')
}
// 参考图 URL 有效性
for (const [sName, sConf] of Object.entries(styles)) {
for (const ref of (sConf.references || [])) {
if (!ref.url) issues.push(`styles.${sName}: 参考图 ${ref.file} 缺少 url未上传 OSS`)
}
}
if (issues.length === 0) {
console.log(`✓ 账号校验通过: ${accountId}`)
console.log(` ${config.name}, 模型: ${config.imageModel}+${config.videoModel || '(未指定)'}`)
console.log(` 参考图: ${localRefs.length} 本地, 风格: ${styleFiles.length}`)
} else {
console.error(`✗ 发现 ${issues.length} 个问题:`)
issues.forEach(i => console.error(` - ${i}`))
process.exit(1)
}
}
// ============================================================================
// CLI
// ============================================================================
function parseArgs(argv) {
const args = {}
for (let i = 0; i < argv.length; i++) {
if (argv[i] === '--manifest' && argv[i + 1]) args.manifest = argv[++i]
else if (argv[i] === '--account' && argv[i + 1]) args.account = argv[++i]
else if (argv[i] === '--phase' && argv[i + 1]) args.phases = argv[++i].split(',')
else if (argv[i] === '--resume') args.resume = true
else if (argv[i] === '--mode' && argv[i + 1]) args.mode = argv[++i]
else if (argv[i] === '--items' && argv[i + 1]) args.items = argv[++i]
else if (argv[i] === '--items-file' && argv[i + 1]) args.itemsFile = argv[++i]
else if (argv[i] === '--id' && argv[i + 1]) args.id = argv[++i]
else if (argv[i] === '--name' && argv[i + 1]) args.name = argv[++i]
else if (argv[i] === '--desc' && argv[i + 1]) args.desc = argv[++i]
else if (argv[i] === '--format' && argv[i + 1]) args.format = argv[++i]
else if (argv[i] === '--image-model' && argv[i + 1]) args.imageModel = argv[++i]
else if (argv[i] === '--video-model' && argv[i + 1]) args.videoModel = argv[++i]
else if (argv[i] === '--references' && argv[i + 1]) args.references = argv[++i]
else if (argv[i] === '--style' && argv[i + 1]) args.style = argv[++i]
else if (!args.command) args.command = argv[i]
}
return args
}
async function main() {
const args = parseArgs(process.argv.slice(2))
const command = args.command
if (command === 'init') {
initManifest(args)
return
}
if (command === 'validate') {
if (!args.manifest) { console.error('用法: pipeline.js validate --manifest <path>'); process.exit(1) }
validateManifest(args.manifest)
return
}
if (command === 'status') {
if (!args.manifest) { console.error('用法: pipeline.js status --manifest <path>'); process.exit(1) }
showStatus(args.manifest)
return
}
if (command === 'run') {
if (!args.manifest) { console.error('用法: pipeline.js run --manifest <path> [--account id] [--phase p1,p2] [--resume]'); process.exit(1) }
await runPipeline(args.manifest, args)
return
}
if (command === 'create-account') {
await createAccount(args)
return
}
if (command === 'validate-account') {
if (!args.account) { console.error('用法: pipeline.js validate-account --account <id>'); process.exit(1) }
validateAccount(args.account)
return
}
console.log('用法:')
console.log(' pipeline.js create-account --id <id> --name <名称> [--desc ...] [--references file1,file2]')
console.log(' pipeline.js validate-account --account <id>')
console.log(' pipeline.js init --account <id> --mode <single|framePair> --items <JSON> [--items-file <path>]')
console.log(' pipeline.js validate --manifest <path>')
console.log(' pipeline.js run --manifest <path> [--account id] [--phase p1,p2] [--resume]')
console.log(' pipeline.js status --manifest <path>')
console.log('')
console.log('Manifest 路径约定: output/{account}_{date}_{NNN}/manifest.json同天自增序号')
console.log('阶段: images, upload, videos, tts, assemble')
}
if (require.main === module) {
main().catch(err => {
console.error(`\n错误: ${err.message}`)
process.exit(1)
})
}
module.exports = { runPipeline, showStatus, initManifest, validateManifest, createAccount, validateAccount }