1081 lines
37 KiB
JavaScript
1081 lines
37 KiB
JavaScript
|
|
#!/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.references(agent 创建时写入)
|
|||
|
|
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 降级为 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.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'
|
|||
|
|
|
|||
|
|
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
|
|||
|
|
if (videoModel.includes('grok')) {
|
|||
|
|
generator = require('./grok-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,
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
// 将结果写回 manifest(generators 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. 构建 items(AI 只提供创意字段,脚本补齐 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',
|
|||
|
|
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('缺少顶层 mode(single 或 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} 首尾帧模式缺少 lastFramePrompt(imagePrompt 作为第一帧)`)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
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 }
|