新增 `batch-pipeline.js` 脚本,实现从 Excel/CSV 批量创建视频生产任务的核心功能: - 支持 `init`、`status`、`mark`、`retry-failed`、`next` 等 CLI 子命令 - 解析表格数据,提取脚本、账号、模式等字段,生成 batch-manifest.json 元数据 - 支持 `xlsx`/`csv` 格式输入,自动校验账号存在性 - 提供状态追踪与任务重试机制,为 Orchestrator-Worker 批量模式奠定基础 同时更新 `CLAUDE.md` 文档,添加批量生产功能的操作指南。
454 lines
15 KiB
JavaScript
454 lines
15 KiB
JavaScript
#!/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 }
|