- 新增 minimax-tts.js 和 minimax-voice-clone.js 脚本 - 新增口播批量剪辑流水线 (口播_assemble.js, 口播_pipeline.js) - 更新 video-from-script 各阶段脚本 (kling, images, tts, videos) - 新增执黑先行二号-风格延伸账号 - 更新执黑先行 account.json 配置 - 替换 ugc_product_seeding 参考图 - 更新 CLAUDE.md 和依赖配置 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
574 lines
20 KiB
JavaScript
574 lines
20 KiB
JavaScript
#!/usr/bin/env node
|
||
|
||
/**
|
||
* 口播视频生产编排器
|
||
*
|
||
* 流程: 视频 → ASR转写 → 用户编辑字幕 → 裁剪组装 → 封面生成 → 输出表格
|
||
*
|
||
* 用法:
|
||
* # 单视频
|
||
* node 口播_pipeline.js init --video <路径> --name <标题> [--account <账号>]
|
||
* node 口播_pipeline.js assemble --manifest <path> [--topic-a <封面标题>] [--forward-copy <转发文案>] [--hashtags <话题>]
|
||
*
|
||
* # 批量
|
||
* node 口播_pipeline.js batch-init --file <csv> [--account <账号>]
|
||
* node 口播_pipeline.js batch-assemble --file <batch-manifest> --row <N>
|
||
* node 口播_pipeline.js export --file <batch-manifest> [--format csv|xlsx]
|
||
*/
|
||
|
||
const fs = require('fs')
|
||
const path = require('path')
|
||
const { execSync, execFileSync } = require('child_process')
|
||
const os = require('os')
|
||
|
||
const SKILLS_DIR = path.resolve(__dirname, '..')
|
||
const ACCOUNTS_DIR = path.resolve(SKILLS_DIR, '..', '..', 'accounts')
|
||
const OUTPUT_BASE = path.resolve(SKILLS_DIR, '..', '..', 'output')
|
||
|
||
// ============================================================================
|
||
// 工具函数
|
||
// ============================================================================
|
||
|
||
function ensureDir(dir) { fs.mkdirSync(dir, { recursive: true }) }
|
||
function readJson(p) { return JSON.parse(fs.readFileSync(p, 'utf-8')) }
|
||
function writeJson(p, data) {
|
||
ensureDir(path.dirname(p))
|
||
fs.writeFileSync(p, JSON.stringify(data, null, 2), 'utf-8')
|
||
}
|
||
function formatDate(d) {
|
||
return [d.getFullYear(), String(d.getMonth()+1).padStart(2,'0'), String(d.getDate()).padStart(2,'0')].join('')
|
||
}
|
||
function calcStats(items) {
|
||
const stats = { total: items.length, pending: 0, processing: 0, completed: 0, failed: 0 }
|
||
for (const it of items) {
|
||
if (stats[it.status] !== undefined) stats[it.status]++
|
||
else stats.pending++
|
||
}
|
||
return stats
|
||
}
|
||
|
||
// ============================================================================
|
||
// CLI 参数解析
|
||
// ============================================================================
|
||
|
||
function parseArgs(argv) {
|
||
const args = {}
|
||
for (let i = 0; i < argv.length; i++) {
|
||
const k = argv[i]
|
||
if (k.startsWith('--') && argv[i+1] && !argv[i+1].startsWith('--')) {
|
||
args[k.slice(2)] = argv[++i]
|
||
} else if (k.startsWith('--')) {
|
||
args[k.slice(2)] = true
|
||
} else if (!args.command) {
|
||
args.command = k
|
||
}
|
||
}
|
||
return args
|
||
}
|
||
|
||
// ============================================================================
|
||
// ASR 转写(调用 Paraformer)
|
||
// ============================================================================
|
||
|
||
function runAsr(videoPath, outputPath) {
|
||
const asrScript = path.resolve(os.homedir(), '.claude', 'scripts', 'asr_transcribe.py')
|
||
if (!fs.existsSync(asrScript)) {
|
||
throw new Error(`ASR 脚本不存在: ${asrScript}`)
|
||
}
|
||
console.log(' 转写中...')
|
||
execSync(`python3 "${asrScript}" "${videoPath}" --output "${outputPath}"`, {
|
||
stdio: 'pipe',
|
||
timeout: 120000,
|
||
})
|
||
}
|
||
|
||
// ============================================================================
|
||
// 组装(调用 口播_assemble.js)
|
||
// ============================================================================
|
||
|
||
function runAssemble(videoPath, transcriptPath, draftName, speed = 1.1) {
|
||
const scriptPath = path.resolve(__dirname, '口播_assemble.js')
|
||
const result = execFileSync('node', [
|
||
scriptPath,
|
||
'--video', videoPath,
|
||
'--transcript', transcriptPath,
|
||
'--name', draftName,
|
||
'--speed', String(speed),
|
||
], { stdio: 'pipe', timeout: 300000 }).toString()
|
||
|
||
// 从输出中提取 draft_url 和 draft_id
|
||
let draftUrl = ''
|
||
let draftId = ''
|
||
for (const line of result.split('\n')) {
|
||
if (line.includes('草稿地址:')) draftUrl = line.split('草稿地址:')[1].trim()
|
||
if (line.includes('草稿名称:')) draftName = line.split('草稿名称:')[1].trim()
|
||
if (line.includes('draft_id:')) draftId = line.split('draft_id:')[1].trim()
|
||
}
|
||
|
||
console.log(result)
|
||
return { draftUrl, draftName, draftId }
|
||
}
|
||
|
||
// ============================================================================
|
||
// 封面生成(占位 — 后续接入完整封面流程)
|
||
// ============================================================================
|
||
|
||
async function generateCovers(manifest, draftName, topicA) {
|
||
// 封面后续完整实现,当前返回空
|
||
console.log(' 封面: 待账号模板锁定后启用')
|
||
return { cover9x16: '', cover3x4: '' }
|
||
}
|
||
|
||
// ============================================================================
|
||
// === 单视频模式 ===
|
||
// ============================================================================
|
||
|
||
function cmdInit(args) {
|
||
if (!args.video || !args.name) {
|
||
console.error('用法: 口播_pipeline.js init --video <路径> --name <标题> [--account <账号>]')
|
||
process.exit(1)
|
||
}
|
||
|
||
const videoPath = path.resolve(args.video)
|
||
const title = args.name
|
||
const account = args.account || '口播批量剪辑'
|
||
|
||
if (!fs.existsSync(videoPath)) {
|
||
console.error(`视频不存在: ${videoPath}`)
|
||
process.exit(1)
|
||
}
|
||
|
||
// 验证账号
|
||
const accountJsonPath = path.join(ACCOUNTS_DIR, account, 'account.json')
|
||
if (!fs.existsSync(accountJsonPath)) {
|
||
console.error(`账号不存在: ${account},可用账号:`)
|
||
for (const d of fs.readdirSync(ACCOUNTS_DIR)) {
|
||
if (d.startsWith('_')) continue
|
||
if (fs.existsSync(path.join(ACCOUNTS_DIR, d, 'account.json'))) console.error(` - ${d}`)
|
||
}
|
||
process.exit(1)
|
||
}
|
||
|
||
// 创建输出目录
|
||
const dateStr = formatDate(new Date())
|
||
let seq = 1
|
||
while (fs.existsSync(path.join(OUTPUT_BASE, `${account}_${dateStr}_${String(seq).padStart(3,'0')}`))) seq++
|
||
const outDir = path.join(OUTPUT_BASE, `${account}_${dateStr}_${String(seq).padStart(3,'0')}`)
|
||
ensureDir(outDir)
|
||
|
||
// 复制视频到输出目录
|
||
const videoBasename = path.basename(videoPath)
|
||
const localVideo = path.join(outDir, 'source', videoBasename)
|
||
ensureDir(path.dirname(localVideo))
|
||
fs.copyFileSync(videoPath, localVideo)
|
||
console.log(`视频已复制: ${localVideo}`)
|
||
|
||
// ASR 转写
|
||
console.log('[ASR] 语音转文字...')
|
||
const rawTxt = path.join(outDir, 'transcript_raw.txt')
|
||
runAsr(localVideo, rawTxt)
|
||
|
||
// 复制一份作为 edit 模板
|
||
const editTxt = path.join(outDir, 'transcript_edit.txt')
|
||
fs.copyFileSync(rawTxt, editTxt)
|
||
|
||
// 写 manifest
|
||
const manifest = {
|
||
account,
|
||
name: title,
|
||
type: 'talking-head',
|
||
videoPath: `source/${videoBasename}`,
|
||
transcriptRaw: 'transcript_raw.txt',
|
||
transcriptEdit: 'transcript_edit.txt',
|
||
speed: 1.1,
|
||
status: 'editing', // editing → assembled → done
|
||
draftName: '',
|
||
draftUrl: '',
|
||
topicA: '',
|
||
topicB: '',
|
||
forwardCopy: '',
|
||
hashtags: '',
|
||
coverUrl_抖音: '',
|
||
coverUrl_小红书: '',
|
||
createdAt: new Date().toISOString(),
|
||
}
|
||
|
||
const manifestPath = path.join(outDir, 'manifest.json')
|
||
writeJson(manifestPath, manifest)
|
||
|
||
console.log(`\n初始化完成: ${manifestPath}`)
|
||
console.log(` 视频: ${localVideo}`)
|
||
console.log(` 原始字幕: ${rawTxt}`)
|
||
console.log(` 编辑字幕: ${editTxt} ← 请编辑此文件(删气口/错字/冗余)`)
|
||
console.log(`\n编辑完成后运行:`)
|
||
console.log(` node .claude/skills/video-from-script/scripts/口播_pipeline.js assemble --manifest ${manifestPath}`)
|
||
}
|
||
|
||
// ============================================================================
|
||
|
||
function cmdAssemble(args) {
|
||
if (!args.manifest) {
|
||
console.error('用法: 口播_pipeline.js assemble --manifest <path> [--topic-a <封面标题>] [--forward-copy <转发文案>] [--hashtags <话题>]')
|
||
process.exit(1)
|
||
}
|
||
|
||
const manifestPath = path.resolve(args.manifest)
|
||
const manifest = readJson(manifestPath)
|
||
const outDir = path.dirname(manifestPath)
|
||
|
||
const videoPath = path.resolve(outDir, manifest.videoPath)
|
||
const editTxt = path.resolve(outDir, manifest.transcriptEdit)
|
||
|
||
if (!fs.existsSync(videoPath)) { console.error(`视频不存在: ${videoPath}`); process.exit(1) }
|
||
if (!fs.existsSync(editTxt)) { console.error(`字幕文件不存在: ${editTxt},请先编辑 transcript_edit.txt`); process.exit(1) }
|
||
|
||
// 更新元数据
|
||
if (args.topicA) manifest.topicA = args.topicA
|
||
if (args.forwardCopy) manifest.forwardCopy = args.forwardCopy
|
||
if (args.hashtags) manifest.hashtags = args.hashtags
|
||
if (args.topicB) manifest.topicB = args.topicB
|
||
|
||
// 草稿名称
|
||
const today = new Date()
|
||
const mmdd = formatDate(today).slice(4)
|
||
const draftName = `${manifest.account}_${mmdd}_${manifest.name}`
|
||
manifest.draftName = draftName
|
||
|
||
// 组装
|
||
console.log('[Assemble] 视频裁剪 + 字幕 + 倍速...')
|
||
manifest.status = 'assembling'
|
||
writeJson(manifestPath, manifest)
|
||
|
||
const result = runAssemble(videoPath, editTxt, manifest.name, manifest.speed || 1.1)
|
||
manifest.draftUrl = result.draftUrl
|
||
manifest.draftName = result.draftName || draftName
|
||
|
||
// 封面
|
||
console.log('\n[Cover] 封面生成...')
|
||
// 封面后续接入
|
||
|
||
manifest.status = 'done'
|
||
manifest.completedAt = new Date().toISOString()
|
||
writeJson(manifestPath, manifest)
|
||
|
||
// 输出表格行
|
||
const htags = manifest.hashtags || ''
|
||
let forwardBody = (manifest.forwardCopy || '').trim()
|
||
if (htags && forwardBody.endsWith(htags)) forwardBody = forwardBody.slice(0, -htags.length).trim()
|
||
const forwardFull = [forwardBody, htags].filter(Boolean).join('')
|
||
|
||
console.log('\n' + '='.repeat(70))
|
||
console.log('成片输出')
|
||
console.log('='.repeat(70))
|
||
console.log(` 草稿名称: ${manifest.draftName}`)
|
||
console.log(` 选题(封面): ${manifest.topicA || '(未指定)'}`)
|
||
console.log(` 转发文案: ${forwardFull || '(未指定)'}`)
|
||
console.log(` draftUrl: ${manifest.draftUrl}`)
|
||
console.log(` 封面_抖音: ${manifest.coverUrl_抖音 || '(待生成)'}`)
|
||
console.log(` 封面_小红书: ${manifest.coverUrl_小红书 || '(待生成)'}`)
|
||
}
|
||
|
||
// ============================================================================
|
||
// === 批量模式 ===
|
||
// ============================================================================
|
||
|
||
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 []
|
||
|
||
// 解析 CSV 行(处理引号内逗号)
|
||
function parseLine(line) {
|
||
const result = []; let cur = ''; let inQ = false
|
||
for (const ch of line) {
|
||
if (ch === '"') inQ = !inQ
|
||
else if (ch === ',' && !inQ) { result.push(cur); cur = '' }
|
||
else cur += ch
|
||
}
|
||
result.push(cur)
|
||
return result
|
||
}
|
||
|
||
const headers = parseLine(lines[0]).map(h => h.trim())
|
||
const rows = []
|
||
for (let i = 1; i < lines.length; i++) {
|
||
const vals = parseLine(lines[i])
|
||
const row = {}
|
||
headers.forEach((h, j) => { row[h] = (vals[j] || '').trim() })
|
||
if (Object.values(row).some(v => v)) rows.push(row)
|
||
}
|
||
return rows
|
||
}
|
||
|
||
function cmdBatchInit(args) {
|
||
if (!args.file) {
|
||
console.error('用法: 口播_pipeline.js batch-init --file <csv> [--account <账号>]')
|
||
process.exit(1)
|
||
}
|
||
|
||
const csvPath = path.resolve(args.file)
|
||
if (!fs.existsSync(csvPath)) { console.error(`文件不存在: ${csvPath}`); process.exit(1) }
|
||
|
||
const rows = parseCsv(csvPath)
|
||
if (rows.length === 0) { console.error('CSV 为空'); process.exit(1) }
|
||
|
||
const defaultAccount = args.account || '口播批量剪辑'
|
||
|
||
// 创建 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')}`)
|
||
ensureDir(batchDir)
|
||
|
||
// 构建 items
|
||
const items = []
|
||
for (let i = 0; i < rows.length; i++) {
|
||
const row = rows[i]
|
||
const videoPath = row['视频路径'] || row['video'] || row['视频'] || ''
|
||
const title = row['选题'] || row['标题'] || row['title'] || row['名称'] || ''
|
||
const account = row['账号'] || row['account'] || defaultAccount
|
||
const forwardRaw = row['转发文案带话题'] || row['转发文案'] || row['forwardCopy'] || ''
|
||
|
||
if (!videoPath) {
|
||
console.warn(` ⚠ 第 ${i+2} 行缺少视频路径,跳过`)
|
||
continue
|
||
}
|
||
|
||
// 验证视频存在
|
||
if (!fs.existsSync(path.resolve(videoPath))) {
|
||
console.warn(` ⚠ 视频不存在: ${videoPath}`)
|
||
continue
|
||
}
|
||
|
||
// 解析转发文案
|
||
let forwardCopy = forwardRaw; let hashtags = ''
|
||
const hMatch = forwardRaw.match(/(#[^\s#]+)/g)
|
||
if (hMatch) {
|
||
hashtags = hMatch.join('')
|
||
forwardCopy = forwardRaw.replace(/#[^\s#]+/g, '').replace(/\s+/g, ' ').trim()
|
||
}
|
||
|
||
// 复制视频
|
||
const vExt = path.extname(videoPath)
|
||
const localVideo = path.join(batchDir, 'videos', `row_${String(i+1).padStart(3,'0')}${vExt}`)
|
||
ensureDir(path.dirname(localVideo))
|
||
fs.copyFileSync(path.resolve(videoPath), localVideo)
|
||
|
||
// ASR 转写
|
||
const rawTxt = path.join(batchDir, 'transcripts', `row_${String(i+1).padStart(3,'0')}_raw.txt`)
|
||
const editTxt = path.join(batchDir, 'transcripts', `row_${String(i+1).padStart(3,'0')}_edit.txt`)
|
||
ensureDir(path.dirname(rawTxt))
|
||
try {
|
||
runAsr(localVideo, rawTxt)
|
||
fs.copyFileSync(rawTxt, editTxt)
|
||
} catch (e) {
|
||
console.warn(` ⚠ ASR 转写失败: ${e.message}`)
|
||
// 创建一个空文件标记需要手动处理
|
||
fs.writeFileSync(rawTxt, '# ASR 失败,请手动填入字幕\n', 'utf-8')
|
||
fs.copyFileSync(rawTxt, editTxt)
|
||
}
|
||
|
||
items.push({
|
||
row: i + 1,
|
||
title: title || '',
|
||
account,
|
||
status: 'editing', // editing → assembled → done
|
||
videoPath: path.relative(batchDir, localVideo),
|
||
transcriptRaw: path.relative(batchDir, rawTxt),
|
||
transcriptEdit: path.relative(batchDir, editTxt),
|
||
topicA: '',
|
||
topicB: '',
|
||
draftName: '',
|
||
draftUrl: '',
|
||
forwardCopy,
|
||
hashtags,
|
||
coverUrl_抖音: '',
|
||
coverUrl_小红书: '',
|
||
speed: 1.1,
|
||
error: null,
|
||
})
|
||
}
|
||
|
||
if (items.length === 0) {
|
||
console.error('没有有效行')
|
||
process.exit(1)
|
||
}
|
||
|
||
const batchManifest = {
|
||
source: path.basename(csvPath),
|
||
type: 'talking-head-batch',
|
||
createdAt: new Date().toISOString(),
|
||
defaults: { account: defaultAccount, speed: 1.1 },
|
||
stats: calcStats(items),
|
||
items,
|
||
}
|
||
|
||
const manifestPath = path.join(batchDir, 'batch-manifest.json')
|
||
writeJson(manifestPath, batchManifest)
|
||
|
||
console.log(`\n批量任务已创建: ${manifestPath}`)
|
||
console.log(` 来源: ${path.basename(csvPath)}`)
|
||
console.log(` 总数: ${items.length}`)
|
||
console.log(` 账号: ${defaultAccount}`)
|
||
console.log(` 视频目录: ${path.join(batchDir, 'videos')}/`)
|
||
console.log(` 字幕目录: ${path.join(batchDir, 'transcripts')}/`)
|
||
console.log(`\n 请编辑 transcripts/ 目录下的 _edit.txt 文件(删气口/错字/冗余),`)
|
||
console.log(` 编辑完后运行 batch-assemble。`)
|
||
}
|
||
|
||
// ============================================================================
|
||
|
||
function cmdBatchAssemble(args) {
|
||
if (!args.file || !args.row) {
|
||
console.error('用法: 口播_pipeline.js batch-assemble --file <batch-manifest> --row <N>')
|
||
process.exit(1)
|
||
}
|
||
|
||
const manifestPath = path.resolve(args.file)
|
||
const batch = readJson(manifestPath)
|
||
const batchDir = path.dirname(manifestPath)
|
||
|
||
const item = batch.items.find(it => it.row === args.row)
|
||
if (!item) { console.error(`行 ${args.row} 不存在`); process.exit(1) }
|
||
|
||
const videoPath = path.resolve(batchDir, item.videoPath)
|
||
const editTxt = path.resolve(batchDir, item.transcriptEdit)
|
||
|
||
if (!fs.existsSync(videoPath)) { console.error(`视频不存在: ${videoPath}`); process.exit(1) }
|
||
if (!fs.existsSync(editTxt)) { console.error(`字幕文件不存在: ${editTxt}`); process.exit(1) }
|
||
|
||
const today = new Date()
|
||
const mmdd = formatDate(today).slice(4)
|
||
const shortTitle = item.topicB || item.title || `row${String(item.row).padStart(2,'0')}`
|
||
const draftName = `${item.account}_${mmdd}_${String(item.row).padStart(2,'0')}_${shortTitle}`
|
||
|
||
console.log(`[#${item.row}] 组装中...`)
|
||
|
||
// 更新状态
|
||
item.status = 'assembling'
|
||
batch.stats = calcStats(batch.items)
|
||
writeJson(manifestPath, batch)
|
||
|
||
try {
|
||
const result = runAssemble(videoPath, editTxt, shortTitle, item.speed || 1.1)
|
||
item.draftUrl = result.draftUrl
|
||
item.draftName = result.draftName || draftName
|
||
item.status = 'completed'
|
||
item.error = null
|
||
console.log(`[#${item.row}] 组装完成: ${item.draftName}`)
|
||
} catch (e) {
|
||
item.status = 'failed'
|
||
item.error = e.message
|
||
console.error(`[#${item.row}] 组装失败: ${e.message}`)
|
||
}
|
||
|
||
batch.stats = calcStats(batch.items)
|
||
writeJson(manifestPath, batch)
|
||
}
|
||
|
||
// ============================================================================
|
||
// export: 批量输出表格
|
||
// ============================================================================
|
||
|
||
function cmdExport(args) {
|
||
const manifestPath = path.resolve(args.file)
|
||
const batch = readJson(manifestPath)
|
||
|
||
const rows = []
|
||
for (const item of batch.items) {
|
||
const htags = item.hashtags || ''
|
||
let forwardBody = (item.forwardCopy || '').trim()
|
||
if (htags && forwardBody.endsWith(htags)) forwardBody = forwardBody.slice(0, -htags.length).trim()
|
||
const forwardFull = [forwardBody, htags].filter(Boolean).join('')
|
||
|
||
rows.push({
|
||
序号: item.row,
|
||
草稿名称: item.draftName || '',
|
||
选题_封面: item.topicA || item.title || '',
|
||
转发文案带话题: forwardFull,
|
||
draftUrl: item.draftUrl || '',
|
||
封面_抖音: item.coverUrl_抖音 || '',
|
||
封面_小红书: item.coverUrl_小红书 || '',
|
||
})
|
||
}
|
||
|
||
rows.sort((a, b) => a.序号 - b.序号)
|
||
|
||
// 生成文件名
|
||
const accountName = batch.defaults.account || '口播批量剪辑'
|
||
const dateMMDD = formatDate(new Date()).slice(4)
|
||
const nums = rows.map(r => r.序号)
|
||
const rowStart = String(Math.min(...nums)).padStart(2, '0')
|
||
const rowEnd = String(Math.max(...nums)).padStart(2, '0')
|
||
const exportName = `${accountName}_${dateMMDD}_${rowStart}~${rowEnd}`
|
||
|
||
const outPath = path.join(path.dirname(manifestPath), `${exportName}.csv`)
|
||
const headers = ['序号', '草稿名称', '选题_封面', '转发文案带话题', 'draftUrl', '封面_抖音', '封面_小红书']
|
||
|
||
const lines = [headers.join(',')]
|
||
for (const r of rows) {
|
||
const vals = headers.map(h => {
|
||
const v = String(r[h] || '')
|
||
if (v.includes(',') || v.includes('"') || v.includes('\n')) return `"${v.replace(/"/g,'""')}"`
|
||
return v
|
||
})
|
||
lines.push(vals.join(','))
|
||
}
|
||
|
||
fs.writeFileSync(outPath, lines.join('\n'), 'utf-8')
|
||
console.log(`表格已导出: ${outPath}`)
|
||
console.log(` 共 ${rows.length} 条\n`)
|
||
|
||
// 打印表格
|
||
const widths = headers.map(h => {
|
||
const maxD = rows.reduce((m, r) => Math.max(m, String(r[h]||'').length), 0)
|
||
return Math.min(Math.max(maxD, h.length), 50)
|
||
})
|
||
|
||
const sep = '|-' + widths.map(w => '-'.repeat(w)).join('-|-') + '-|'
|
||
const hdr = '| ' + headers.map((h, i) => h.padEnd(widths[i])).join(' | ') + ' |'
|
||
|
||
console.log(sep)
|
||
console.log(hdr)
|
||
console.log(sep)
|
||
for (const r of rows) {
|
||
const line = '| ' + headers.map((h, i) => String(r[h]||'').padEnd(widths[i])).join(' | ') + ' |'
|
||
console.log(line)
|
||
}
|
||
console.log(sep)
|
||
}
|
||
|
||
// ============================================================================
|
||
// CLI 入口
|
||
// ============================================================================
|
||
|
||
function main() {
|
||
const args = parseArgs(process.argv.slice(2))
|
||
const cmd = args.command
|
||
|
||
if (cmd === 'init') cmdInit(args)
|
||
else if (cmd === 'assemble') cmdAssemble(args)
|
||
else if (cmd === 'batch-init') cmdBatchInit(args)
|
||
else if (cmd === 'batch-assemble') cmdBatchAssemble(args)
|
||
else if (cmd === 'export') cmdExport(args)
|
||
else {
|
||
console.log('口播视频生产编排器')
|
||
console.log('')
|
||
console.log('单视频:')
|
||
console.log(' init --video <路径> --name <标题> [--account <账号>]')
|
||
console.log(' assemble --manifest <path> [--topic-a <封面标题>] [--forward-copy <转发文案>] [--hashtags <话题>]')
|
||
console.log('')
|
||
console.log('批量:')
|
||
console.log(' batch-init --file <csv> [--account <账号>]')
|
||
console.log(' batch-assemble --file <batch-manifest> --row <N>')
|
||
console.log(' export --file <batch-manifest> [--format csv|xlsx]')
|
||
console.log('')
|
||
console.log('批量 CSV 格式: 视频路径,选题,转发文案带话题,账号')
|
||
}
|
||
}
|
||
|
||
if (require.main === module) main()
|
||
|
||
module.exports = { cmdInit, cmdAssemble, cmdBatchInit, cmdBatchAssemble, cmdExport }
|