feat: MiniMax TTS集成、口播批量剪辑流水线、执黑先行二号账号

- 新增 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>
This commit is contained in:
lc
2026-05-24 20:05:56 +08:00
parent 817c181cb5
commit 1e110219ff
31 changed files with 5098 additions and 34 deletions

View File

@@ -0,0 +1,573 @@
#!/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 }