Files
video-create/.claude/skills/video-from-script/scripts/pipeline.js
sion123 7d526d2b60 feat(video-pipeline): 重构多阶段生成管线并集成 CosyVoice TTS
- 重写 `phase-images`:改为并发 3 张并行生成,每个 item 完成立即写入 manifest,支持 MJ task ID 恢复
- 重写 `phase-videos`:先恢复已有 task ID 再提交新任务(并发 3),支持中断恢复
- 迁移 TTS 引擎:从 Qwen-TTS HTTP 接口切换为 CosyVoice WebSocket 接口,支持音色/语气参数透传
- 精简账号系统:移除 `styles/` 目录、`taskId` 过滤和 `--id` 正则校验,`references` 改为顶层字段
- 调整 `slugify`:限制中文字符 5 个、其他 10 个,避免文件名过长
- 更新文档:`manifest-schema.md` 中 `narration` 改为完整原文案,`account-creation.md` 新增 TTS 配置项
- 配置更新:默认 TTS 模型切换为 `cosyvoice-v3.5-plus`,新增 `localAudio` 参数
2026-05-01 00:44:18 +08:00

237 lines
8.5 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 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 { 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 }