Files
video-create/.claude/skills/video-from-script/scripts/pipeline.js
sion123 0998fd6ae1 feat(video-pipeline): 重构视频流水线,优化成片时间线规则和状态管理
- 引入 manifest.json 作为唯一状态源,所有子 Agent 操作回写 manifest
- 重构 timebuilder 逻辑,支持四种视频适配策略(加速/裁剪/放缓/画面停顿)
- 统一 TTS 阶段输出结构,单句和多句均写入 segments[]
- 重写字幕和配音生成,基于 segments 精确时长实现音画同步
- 新增 confirm 命令支持按 id 范围确认,上传阶段分离图片和视频
- 添加中间产物写入 output/ 目录的约束,清理废弃配置参数
2026-05-02 00:14:40 +08:00

247 lines
9.1 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 是唯一数据源,支持断点续跑
*
* 模块结构:
* 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 <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 confirm --manifest <path> --items 1,3,5')
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 }