字段重命名涉及所有文档、示例代码、提示词模板以及 slug 生成逻辑,确保管线各阶段(分镜、图生提示词、TTS、成片)一致使用 script 字段承载口播文案。 同时增强 `capcut_assemble.js` 的配音与字幕模块: - 配音支持本地路径模式(不上传 OSS),新增 `--split-captions` 参数启用分句字幕 - 分句字幕按标点切分长文本为短句,基于字数权重动态分配显示时长 - 新增 `update-captions-standalone.js` 独立工具,可在草稿已生成后单独更新字幕
237 lines
8.6 KiB
JavaScript
237 lines
8.6 KiB
JavaScript
#!/usr/bin/env node
|
||
|
||
/**
|
||
* 统一视频生产流程编排器
|
||
*
|
||
* 串联: 生图 → 上传 → 生视频 → TTS → 成片
|
||
* manifest.json 是唯一数据源,支持断点续跑
|
||
*
|
||
* 模块结构:
|
||
* pipeline.js ← CLI 入口 + 编排逻辑(本文件)
|
||
* lib/pipeline-utils.js ← 共享工具函数 & 路径常量
|
||
* lib/phase-*.js ← 各阶段处理器
|
||
* lib/cmd-*.js ← 子命令实现
|
||
*
|
||
* 用法:
|
||
* node scripts/pipeline.js run --manifest ./output/batch/manifest.json --account military --resume
|
||
* node scripts/pipeline.js run --manifest ./output/batch/manifest.json --phase upload,videos
|
||
* node scripts/pipeline.js status --manifest ./output/batch/manifest.json
|
||
*/
|
||
|
||
const { loadManifest, saveManifest, loadAccountConfig, log } = require('./lib/pipeline-utils')
|
||
const { phaseImages } = require('./lib/phase-images')
|
||
const { phaseUpload } = require('./lib/phase-upload')
|
||
const { phaseVideos } = require('./lib/phase-videos')
|
||
const { phaseTts } = require('./lib/phase-tts')
|
||
const { phaseAssemble } = require('./lib/phase-assemble')
|
||
const { initManifest } = require('./lib/cmd-init')
|
||
const { validateManifest, validateAccount } = require('./lib/cmd-validate')
|
||
const { showStatus } = require('./lib/cmd-status')
|
||
const { confirmManifest } = require('./lib/cmd-confirm')
|
||
const { createAccount } = require('./lib/cmd-create-account')
|
||
|
||
// ============================================================================
|
||
// 阶段注册表
|
||
// ============================================================================
|
||
|
||
const ALL_PHASES = ['images', 'upload', 'videos', 'tts', 'assemble']
|
||
|
||
const PHASE_HANDLERS = {
|
||
images: phaseImages,
|
||
upload: phaseUpload,
|
||
videos: phaseVideos,
|
||
tts: phaseTts,
|
||
assemble: phaseAssemble,
|
||
}
|
||
|
||
// ============================================================================
|
||
// Pipeline 编排
|
||
// ============================================================================
|
||
|
||
async function runPipeline(manifestPath, options) {
|
||
const manifest = loadManifest(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
|
||
|
||
if (!manifest.pipeline) {
|
||
manifest.pipeline = { phases: {} }
|
||
}
|
||
|
||
// --retry-failed: 重置失败 item 状态
|
||
if (options.retryFailed) {
|
||
applyRetryFailed(manifest, phases)
|
||
}
|
||
|
||
log('pipeline', `阶段: ${phases.join(' → ')}`)
|
||
|
||
for (const phase of phases) {
|
||
const handler = PHASE_HANDLERS[phase]
|
||
if (!handler) { log('pipeline', `未知阶段: ${phase}`); continue }
|
||
|
||
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 })
|
||
|
||
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', '全部完成')
|
||
}
|
||
|
||
function applyRetryFailed(manifest, phases) {
|
||
let resetCount = 0
|
||
for (const item of manifest.items) {
|
||
if (item.status === 'failed' || item.status === 'partial') {
|
||
if (item.url && item.videoPrompt && !item.video) {
|
||
item.status = 'done'
|
||
item.error = ''
|
||
resetCount++
|
||
} else if (!item.url && item.imagePrompt) {
|
||
item.status = 'pending'
|
||
item.error = ''
|
||
resetCount++
|
||
}
|
||
}
|
||
}
|
||
if (phases.includes('videos')) {
|
||
if (manifest.items.some(it => it.status === 'done' && it.url && it.videoPrompt && !it.video)) {
|
||
manifest.pipeline.phases.videos = 'pending'
|
||
}
|
||
}
|
||
if (phases.includes('images')) {
|
||
if (manifest.items.some(it => !it.status || it.status === 'pending')) {
|
||
manifest.pipeline.phases.images = 'pending'
|
||
}
|
||
}
|
||
if (phases.includes('upload')) manifest.pipeline.phases.upload = 'pending'
|
||
if (phases.includes('tts')) manifest.pipeline.phases.tts = 'pending'
|
||
if (resetCount > 0) log('pipeline', `重置 ${resetCount} 个失败 item (--retry-failed)`)
|
||
}
|
||
|
||
// ============================================================================
|
||
// 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] === '--retry-failed') args.retryFailed = 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 (argv[i] === '--all') args.all = true
|
||
else if (!args.command) args.command = argv[i]
|
||
}
|
||
return args
|
||
}
|
||
|
||
// ============================================================================
|
||
// CLI 入口
|
||
// ============================================================================
|
||
|
||
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 === 'confirm') {
|
||
confirmManifest(args)
|
||
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] [--retry-failed]'); 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>] [--image-model gemini|mj] [--video-model veo3-fast|grok|kling] [--format 9:16]')
|
||
console.log(' pipeline.js validate --manifest <path>')
|
||
console.log(' pipeline.js confirm --manifest <path> --all')
|
||
console.log(' pipeline.js run --manifest <path> [--account id] [--phase p1,p2] [--resume] [--retry-failed]')
|
||
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 }
|