#!/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) { // 图片未上传 → 重试图片阶段 // 如果首帧已存在但 lastFrame 失败,只重置 lastFrame 相关 if (item.file && manifest.mode === 'framePair' && !item.lastFrame) { item.status = 'done' // 保留首帧,只补 lastFrame item.error = '' resetCount++ } else { item.status = 'pending' item.error = '' delete item.file // 清除旧文件引用,避免重复 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') || (it.status === 'done' && manifest.mode === 'framePair' && !it.lastFrame))) { 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] === '--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 '); 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 '); process.exit(1) } showStatus(args.manifest) return } if (command === 'run') { if (!args.manifest) { console.error('用法: pipeline.js run --manifest [--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 '); process.exit(1) } validateAccount(args.account) return } console.log('用法:') console.log(' pipeline.js create-account --id --name <名称> [--desc ...] [--references file1,file2]') console.log(' pipeline.js validate-account --account ') console.log(' pipeline.js init --account --mode --items [--items-file ] [--image-model gemini|mj] [--video-model veo3-fast|grok|kling] [--format 9:16]') console.log(' pipeline.js validate --manifest ') console.log(' pipeline.js confirm --manifest --all') console.log(' pipeline.js confirm --manifest --items 1,3,5') console.log(' pipeline.js run --manifest [--account id] [--phase p1,p2] [--resume] [--retry-failed]') console.log(' pipeline.js status --manifest ') 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 }