diff --git a/.claude/skills/video-from-script/scripts/batch-pipeline.js b/.claude/skills/video-from-script/scripts/batch-pipeline.js new file mode 100644 index 0000000..22e0942 --- /dev/null +++ b/.claude/skills/video-from-script/scripts/batch-pipeline.js @@ -0,0 +1,453 @@ +#!/usr/bin/env node + +/** + * 批量视频生产编排器 + * + * 职责:读 Excel/CSV → 创建 batch-manifest → 管理 batch 状态 + * 不负责:分镜、生图、生视频(由 AI Worker 子 Agent 承担) + * + * 用法: + * node batch-pipeline.js init --file [--account <账号>] [--mode <模式>] + * node batch-pipeline.js status --file + * node batch-pipeline.js mark --file --row --status <状态> [--manifest-path ] [--error ] + * node batch-pipeline.js retry-failed --file + */ + +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 [--account <账号>] [--mode <模式>]') + process.exit(1) + } + cmdInit(args) + } else if (command === 'status') { + if (!args.file) { + console.error('用法: batch-pipeline.js status --file ') + process.exit(1) + } + cmdStatus(args) + } else if (command === 'mark') { + if (!args.file || !args.row || !args.status) { + console.error('用法: batch-pipeline.js mark --file --row --status [--manifest-path ] [--error ]') + process.exit(1) + } + cmdMark(args) + } else if (command === 'retry-failed') { + if (!args.file) { + console.error('用法: batch-pipeline.js retry-failed --file ') + process.exit(1) + } + cmdRetryFailed(args) + } else if (command === 'next') { + if (!args.file) { + console.error('用法: batch-pipeline.js next --file ') + process.exit(1) + } + cmdNext(args) + } else { + console.log('批量视频生产编排器') + console.log('') + console.log('用法:') + console.log(' batch-pipeline.js init --file [--account <账号>] [--mode ]') + console.log(' batch-pipeline.js status --file ') + console.log(' batch-pipeline.js next --file ') + console.log(' batch-pipeline.js mark --file <...> --row --status [--manifest-path ] [--error ]') + console.log(' batch-pipeline.js retry-failed --file ') + 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 } diff --git a/.claude/skills/video-from-script/scripts/package-lock.json b/.claude/skills/video-from-script/scripts/package-lock.json index fe7fb89..4eada70 100644 --- a/.claude/skills/video-from-script/scripts/package-lock.json +++ b/.claude/skills/video-from-script/scripts/package-lock.json @@ -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", diff --git a/.claude/skills/video-from-script/scripts/package.json b/.claude/skills/video-from-script/scripts/package.json index a5dab1e..ffd19f9 100644 --- a/.claude/skills/video-from-script/scripts/package.json +++ b/.claude/skills/video-from-script/scripts/package.json @@ -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" } } diff --git a/CLAUDE.md b/CLAUDE.md index 464133b..5655d8c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 --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 --status [--manifest-path ] [--error ] + +# 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 元数据,不读脚本正文 +- 脚本正文通过文件路径传给 Worker,Worker 自行 Read +- 批量模式下人工确认环节自动跳过(`confirm --all`) **草稿 = CapCut 剪映项目文件**,由 pipeline 的 `assemble` 阶段生成,输出到本地剪映目录。