feat(video-from-script): 添加批量视频生产编排器

新增 `batch-pipeline.js` 脚本,实现从 Excel/CSV 批量创建视频生产任务的核心功能:
- 支持 `init`、`status`、`mark`、`retry-failed`、`next` 等 CLI 子命令
- 解析表格数据,提取脚本、账号、模式等字段,生成 batch-manifest.json 元数据
- 支持 `xlsx`/`csv` 格式输入,自动校验账号存在性
- 提供状态追踪与任务重试机制,为 Orchestrator-Worker 批量模式奠定基础

同时更新 `CLAUDE.md` 文档,添加批量生产功能的操作指南。
This commit is contained in:
2026-05-08 23:06:17 +08:00
parent 4e03ed7197
commit cfdf30d438
4 changed files with 597 additions and 2 deletions

View File

@@ -0,0 +1,453 @@
#!/usr/bin/env node
/**
* 批量视频生产编排器
*
* 职责:读 Excel/CSV → 创建 batch-manifest → 管理 batch 状态
* 不负责:分镜、生图、生视频(由 AI Worker 子 Agent 承担)
*
* 用法:
* node batch-pipeline.js init --file <xlsx/csv> [--account <账号>] [--mode <模式>]
* node batch-pipeline.js status --file <batch-manifest.json>
* node batch-pipeline.js mark --file <batch-manifest.json> --row <N> --status <状态> [--manifest-path <path>] [--error <msg>]
* node batch-pipeline.js retry-failed --file <batch-manifest.json>
*/
const fs = require('fs')
const path = require('path')
const { SKILLS_DIR, ACCOUNTS_DIR } = require('./lib/pipeline-utils')
// output/ 在项目根的父级(美图/output/
const OUTPUT_BASE = path.join(SKILLS_DIR, '..', '..', '..', 'output')
// ============================================================================
// CLI 参数解析
// ============================================================================
function parseArgs(argv) {
const args = {}
for (let i = 0; i < argv.length; i++) {
if (argv[i] === '--file' && argv[i + 1]) args.file = argv[++i]
else if (argv[i] === '--account' && argv[i + 1]) args.account = argv[++i]
else if (argv[i] === '--mode' && argv[i + 1]) args.mode = argv[++i]
else if (argv[i] === '--row' && argv[i + 1]) args.row = parseInt(argv[++i])
else if (argv[i] === '--status' && argv[i + 1]) args.status = argv[++i]
else if (argv[i] === '--manifest-path' && argv[i + 1]) args.manifestPath = argv[++i]
else if (argv[i] === '--error' && argv[i + 1]) args.error = argv[++i]
else if (!args.command) args.command = argv[i]
}
return args
}
// ============================================================================
// init: 读 Excel/CSV → batch-manifest.json + 提取脚本文件
// ============================================================================
function cmdInit(args) {
const filePath = path.resolve(args.file)
if (!fs.existsSync(filePath)) {
console.error(`文件不存在: ${filePath}`)
process.exit(1)
}
const ext = path.extname(filePath).toLowerCase()
let rows
if (ext === '.csv') {
rows = parseCsv(filePath)
} else if (ext === '.xlsx' || ext === '.xls') {
rows = parseExcel(filePath)
} else {
console.error(`不支持的格式: ${ext},仅支持 .xlsx .xls .csv`)
process.exit(1)
}
if (rows.length === 0) {
console.error('表格为空(无数据行)')
process.exit(1)
}
// 创建 batch 输出目录
const dateStr = formatDate(new Date())
let seq = 1
while (fs.existsSync(path.join(OUTPUT_BASE, `batch_${dateStr}_${String(seq).padStart(3, '0')}`))) {
seq++
}
const batchDir = path.join(OUTPUT_BASE, `batch_${dateStr}_${String(seq).padStart(3, '0')}`)
const scriptsDir = path.join(batchDir, 'scripts')
ensureDir(batchDir)
ensureDir(scriptsDir)
const defaultAccount = args.account || ''
const defaultMode = args.mode || 'single'
// 构建 items + 提取脚本
const items = []
for (let i = 0; i < rows.length; i++) {
const row = rows[i]
const script = extractField(row, ['脚本', 'script', '文案', '旁白'])
const title = extractField(row, ['选题', '标题', 'title', 'name']) || `视频${i + 1}`
const account = extractField(row, ['账号', 'account']) || defaultAccount
const mode = extractField(row, ['模式', 'mode']) || defaultMode
if (!script || !script.trim()) {
console.warn(` ⚠ 第 ${i + 2} 行(${title})脚本为空,跳过`)
continue
}
const scriptFile = path.join(scriptsDir, `row_${String(i + 1).padStart(3, '0')}.txt`)
fs.writeFileSync(scriptFile, script.trim(), 'utf-8')
items.push({
row: i + 1,
title,
account: account || defaultAccount,
mode: mode || defaultMode,
scriptFile: `scripts/row_${String(i + 1).padStart(3, '0')}.txt`,
status: 'pending',
manifestPath: null,
error: null,
})
}
if (items.length === 0) {
console.error('没有有效的脚本行')
process.exit(1)
}
// 校验账号
validateAccounts(items)
// 写 batch-manifest
const batchManifest = {
source: path.basename(filePath),
createdAt: new Date().toISOString(),
defaults: { account: defaultAccount, mode: defaultMode },
stats: calcStats(items),
items,
}
const manifestPath = path.join(batchDir, 'batch-manifest.json')
writeJson(manifestPath, batchManifest)
console.log(`\n批量任务已创建: ${manifestPath}`)
console.log(` 来源: ${path.basename(filePath)}`)
console.log(` 总数: ${items.length}`)
console.log(` 默认账号: ${defaultAccount || '(未指定,需每行填写)'}`)
console.log(` 默认模式: ${defaultMode}`)
console.log(` 脚本目录: ${scriptsDir}/`)
console.log()
}
// ============================================================================
// status: 展示批量进度
// ============================================================================
function cmdStatus(args) {
const manifestPath = path.resolve(args.file)
const batch = readJson(manifestPath)
const batchDir = path.dirname(manifestPath)
console.log(`\n批量任务: ${batch.source}`)
console.log(` 创建时间: ${batch.createdAt}`)
console.log(` 进度: ${batch.stats.completed}/${batch.stats.total} 完成`)
console.log()
const grouped = { pending: [], processing: [], completed: [], failed: [] }
for (const item of batch.items) {
const list = grouped[item.status] || grouped.pending
list.push(item)
}
if (grouped.completed.length > 0) {
console.log(` ✅ 完成 (${grouped.completed.length}):`)
for (const it of grouped.completed) {
console.log(` #${it.row} ${it.title}${it.manifestPath || ''}`)
}
}
if (grouped.failed.length > 0) {
console.log(` ❌ 失败 (${grouped.failed.length}):`)
for (const it of grouped.failed) {
console.log(` #${it.row} ${it.title}${it.error || '未知错误'}`)
}
}
if (grouped.processing.length > 0) {
console.log(` 🔄 进行中 (${grouped.processing.length}):`)
for (const it of grouped.processing) {
console.log(` #${it.row} ${it.title}`)
}
}
if (grouped.pending.length > 0) {
console.log(` ⏳ 待处理 (${grouped.pending.length}):`)
for (const it of grouped.pending) {
console.log(` #${it.row} ${it.title} (账号: ${it.account || '未指定'}, 模式: ${it.mode})`)
}
}
// 输出下一个待处理的行号(方便 AI agent 消费)
const next = batch.items.find(it => it.status === 'pending')
if (next) {
console.log(`\n ▶ 下一条: #${next.row} (账号: ${next.account}, 模式: ${next.mode})`)
console.log(` 脚本文件: ${path.resolve(batchDir, next.scriptFile)}`)
}
console.log()
}
// ============================================================================
// mark: 标记某行状态
// ============================================================================
function cmdMark(args) {
const manifestPath = path.resolve(args.file)
const batch = readJson(manifestPath)
const item = batch.items.find(it => it.row === args.row)
if (!item) {
console.error(`${args.row} 不存在`)
process.exit(1)
}
const validStatuses = ['pending', 'processing', 'completed', 'failed']
if (!validStatuses.includes(args.status)) {
console.error(`无效状态: ${args.status},可选: ${validStatuses.join(', ')}`)
process.exit(1)
}
const oldStatus = item.status
item.status = args.status
if (args.manifestPath) item.manifestPath = args.manifestPath
if (args.error) item.error = args.error
if (args.status !== 'failed') item.error = null
batch.stats = calcStats(batch.items)
writeJson(manifestPath, batch)
console.log(`#${item.row} ${item.title}: ${oldStatus}${args.status}`)
}
// ============================================================================
// retry-failed: 重置失败行
// ============================================================================
function cmdRetryFailed(args) {
const manifestPath = path.resolve(args.file)
const batch = readJson(manifestPath)
let count = 0
for (const item of batch.items) {
if (item.status === 'failed') {
item.status = 'pending'
item.error = null
count++
}
}
batch.stats = calcStats(batch.items)
writeJson(manifestPath, batch)
console.log(`已重置 ${count} 条失败记录为 pending`)
}
// ============================================================================
// next: 输出下一条待处理的信息(机器友好格式)
// ============================================================================
function cmdNext(args) {
const manifestPath = path.resolve(args.file)
const batch = readJson(manifestPath)
const batchDir = path.dirname(manifestPath)
const item = batch.items.find(it => it.status === 'pending')
if (!item) {
console.log(JSON.stringify({ done: true }))
return
}
console.log(JSON.stringify({
done: false,
row: item.row,
title: item.title,
account: item.account,
mode: item.mode,
scriptFile: path.resolve(batchDir, item.scriptFile),
}))
}
// ============================================================================
// Helpers
// ============================================================================
function ensureDir(dir) {
fs.mkdirSync(dir, { recursive: true })
}
function readJson(filePath) {
return JSON.parse(fs.readFileSync(filePath, 'utf-8'))
}
function writeJson(filePath, data) {
const tmp = filePath + '.tmp'
fs.writeFileSync(tmp, JSON.stringify(data, null, 2), 'utf-8')
fs.renameSync(tmp, filePath)
}
function calcStats(items) {
const stats = { total: items.length, pending: 0, processing: 0, completed: 0, failed: 0 }
for (const item of items) {
if (stats[item.status] !== undefined) stats[item.status]++
else stats.pending++
}
return stats
}
function formatDate(d) {
return [
d.getFullYear(),
String(d.getMonth() + 1).padStart(2, '0'),
String(d.getDate()).padStart(2, '0'),
].join('')
}
function extractField(row, names) {
for (const name of names) {
if (row[name] != null && String(row[name]).trim()) return String(row[name]).trim()
}
return ''
}
function validateAccounts(items) {
const uniqueAccounts = [...new Set(items.map(it => it.account).filter(Boolean))]
const missing = uniqueAccounts.filter(acc => {
return !fs.existsSync(path.join(ACCOUNTS_DIR, acc, 'account.json'))
})
if (missing.length > 0) {
console.warn(`\n ⚠ 以下账号不存在: ${missing.join(', ')}`)
const available = fs.readdirSync(ACCOUNTS_DIR).filter(d => {
if (d.startsWith('_')) return false
return fs.existsSync(path.join(ACCOUNTS_DIR, d, 'account.json'))
})
console.warn(' 可用账号:')
for (const acc of available) {
try {
const cfg = readJson(path.join(ACCOUNTS_DIR, acc, 'account.json'))
console.warn(` - ${acc} (${cfg.name})`)
} catch {}
}
console.warn()
}
}
// ============================================================================
// Excel / CSV 解析
// ============================================================================
function parseExcel(filePath) {
try {
const XLSX = require('xlsx')
const workbook = XLSX.readFile(filePath)
const sheetName = workbook.SheetNames[0]
const sheet = workbook.Sheets[sheetName]
return XLSX.utils.sheet_to_json(sheet)
} catch (err) {
if (err.code === 'MODULE_NOT_FOUND') {
console.error('需要安装 xlsx: cd scripts && pnpm add xlsx')
process.exit(1)
}
throw err
}
}
function parseCsv(filePath) {
const content = fs.readFileSync(filePath, 'utf-8')
const lines = content.split(/\r?\n/).filter(l => l.trim())
if (lines.length < 2) return []
const headers = parseCsvLine(lines[0])
const rows = []
for (let i = 1; i < lines.length; i++) {
const values = parseCsvLine(lines[i])
const row = {}
headers.forEach((h, j) => { row[h.trim()] = (values[j] || '').trim() })
rows.push(row)
}
return rows
}
function parseCsvLine(line) {
const result = []
let current = ''
let inQuotes = false
for (const char of line) {
if (char === '"') { inQuotes = !inQuotes }
else if (char === ',' && !inQuotes) { result.push(current); current = '' }
else { current += char }
}
result.push(current)
return result
}
// ============================================================================
// CLI 入口
// ============================================================================
function main() {
const args = parseArgs(process.argv.slice(2))
const command = args.command
if (command === 'init') {
if (!args.file) {
console.error('用法: batch-pipeline.js init --file <xlsx/csv> [--account <账号>] [--mode <模式>]')
process.exit(1)
}
cmdInit(args)
} else if (command === 'status') {
if (!args.file) {
console.error('用法: batch-pipeline.js status --file <batch-manifest.json>')
process.exit(1)
}
cmdStatus(args)
} else if (command === 'mark') {
if (!args.file || !args.row || !args.status) {
console.error('用法: batch-pipeline.js mark --file <batch-manifest.json> --row <N> --status <pending|processing|completed|failed> [--manifest-path <path>] [--error <msg>]')
process.exit(1)
}
cmdMark(args)
} else if (command === 'retry-failed') {
if (!args.file) {
console.error('用法: batch-pipeline.js retry-failed --file <batch-manifest.json>')
process.exit(1)
}
cmdRetryFailed(args)
} else if (command === 'next') {
if (!args.file) {
console.error('用法: batch-pipeline.js next --file <batch-manifest.json>')
process.exit(1)
}
cmdNext(args)
} else {
console.log('批量视频生产编排器')
console.log('')
console.log('用法:')
console.log(' batch-pipeline.js init --file <xlsx/csv> [--account <账号>] [--mode <single|framePair>]')
console.log(' batch-pipeline.js status --file <batch-manifest.json>')
console.log(' batch-pipeline.js next --file <batch-manifest.json>')
console.log(' batch-pipeline.js mark --file <...> --row <N> --status <pending|processing|completed|failed> [--manifest-path <path>] [--error <msg>]')
console.log(' batch-pipeline.js retry-failed --file <batch-manifest.json>')
console.log('')
console.log('Excel 格式:')
console.log(' 选题 | 脚本 | 账号 | 模式')
console.log(' 选题/标题/title — 标题(可选)')
console.log(' 脚本/文案/旁白 — 口播文案(必填)')
console.log(' 账号/account — 账号ID可选可由 --account 指定默认值)')
console.log(' 模式/mode — single|framePair可选可由 --mode 指定默认值)')
}
}
if (require.main === module) {
main()
}
module.exports = { cmdInit, cmdStatus, cmdMark, cmdRetryFailed, cmdNext }

View File

@@ -7,7 +7,8 @@
"dependencies": {
"ali-oss": "^6.21.0",
"axios": "^1.15.2",
"sharp": "^0.34.5"
"sharp": "^0.34.5",
"xlsx": "^0.18.5"
}
},
"node_modules/@img/colour": {
@@ -47,6 +48,15 @@
"node": ">= 10.0.0"
}
},
"node_modules/adler-32": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz",
"integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/agentkeepalive": {
"version": "3.5.3",
"resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-3.5.3.tgz",
@@ -159,6 +169,28 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/cfb": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz",
"integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.3.0",
"crc-32": "~1.2.0"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/codepage": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
"integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
@@ -192,6 +224,18 @@
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
"license": "MIT"
},
"node_modules/crc-32": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
"license": "Apache-2.0",
"bin": {
"crc32": "bin/crc32.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/dateformat": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/dateformat/-/dateformat-2.2.0.tgz",
@@ -416,6 +460,15 @@
"pause-stream": "~0.0.11"
}
},
"node_modules/frac": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz",
"integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
@@ -992,6 +1045,18 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/ssf": {
"version": "0.11.2",
"resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
"integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
"license": "Apache-2.0",
"dependencies": {
"frac": "~1.1.2"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/statuses": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz",
@@ -1151,12 +1216,51 @@
"semver": "bin/semver"
}
},
"node_modules/wmf": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
"integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/word": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz",
"integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
},
"node_modules/xlsx": {
"version": "0.18.5",
"resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz",
"integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.3.0",
"cfb": "~1.2.1",
"codepage": "~1.15.0",
"crc-32": "~1.2.1",
"ssf": "~0.11.2",
"wmf": "~1.0.1",
"word": "~0.3.0"
},
"bin": {
"xlsx": "bin/xlsx.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/xml2js": {
"version": "0.6.2",
"resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz",

View File

@@ -2,6 +2,7 @@
"dependencies": {
"ali-oss": "^6.21.0",
"axios": "^1.15.2",
"sharp": "^0.34.5"
"sharp": "^0.34.5",
"xlsx": "^0.18.5"
}
}

View File

@@ -9,6 +9,7 @@
| 生图、批量出图、MJ、Gemini | `image-generator` |
| 成片、组装、剪映、图片轮播 | `capcut` |
| 做视频、图文成片、图生视频、首尾帧 | `video-from-script` |
| 批量生产、给Excel出视频 | `video-from-script`(批量模式,见下方) |
| 创建账号、新账号 | 参考 [account-creation.md](.claude/skills/video-from-script/references/account-creation.md) |
# 工作流
@@ -30,6 +31,42 @@
| 重新生成草稿、重做草稿、草稿再生 | 1. 将 manifest 中 `pipeline.phases.assemble` 改为 `"pending"`2. 执行 `node .claude/skills/video-from-script/scripts/pipeline.js run --manifest output/{name}/manifest.json --phase assemble` |
| 查看草稿进度、草稿状态 | `node .claude/skills/video-from-script/scripts/pipeline.js status --manifest output/{name}/manifest.json` |
| 重跑某个阶段 | 将 manifest 中对应 phase 改为 `"pending"`,再跑 `--phase <阶段名>`。阶段: `images``upload``videos``tts``assemble` |
| 批量生产、给Excel出视频 | 见下方「批量生产」 |
**草稿 = CapCut 剪映项目文件**,由 pipeline 的 `assemble` 阶段生成,输出到本地剪映目录。
# 批量生产
用户给一个 Excel/CSV每行一条视频Agent 逐条 spawn Worker 子 Agent 执行完整 pipeline。
**Excel 格式:** `选题 | 脚本 | 账号 | 模式`(账号/模式可选,可由 CLI 参数指定默认值)
**CLI 命令:**
```bash
# 1. 初始化批量任务
node .claude/skills/video-from-script/scripts/batch-pipeline.js init --file <xlsx/csv> --account <默认账号> --mode single
# 2. 查看进度
node .claude/skills/video-from-script/scripts/batch-pipeline.js status --file output/batch_XXX/batch-manifest.json
# 3. 获取下一条待处理JSON 格式)
node .claude/skills/video-from-script/scripts/batch-pipeline.js next --file output/batch_XXX/batch-manifest.json
# 4. 标记状态
node .claude/skills/video-from-script/scripts/batch-pipeline.js mark --file ... --row <N> --status <completed|failed> [--manifest-path <path>] [--error <msg>]
# 5. 重跑失败项
node .claude/skills/video-from-script/scripts/batch-pipeline.js retry-failed --file output/batch_XXX/batch-manifest.json
```
**执行策略Orchestrator-Worker**
- **Orchestrator主 Agent**:读 batch-manifest 元数据,逐条 spawn Worker 子 Agent收集结果
- **Worker子 Agent**:独立上下文,处理单条视频的完整流程(分镜 → 生图 → 生视频 → TTS → 成片)
- Orchestrator 上下文只存 batch-manifest 元数据,不读脚本正文
- 脚本正文通过文件路径传给 WorkerWorker 自行 Read
- 批量模式下人工确认环节自动跳过(`confirm --all`
**草稿 = CapCut 剪映项目文件**,由 pipeline 的 `assemble` 阶段生成,输出到本地剪映目录。