Files
video-create/.claude/skills/video-from-script/scripts/口播_pipeline.js
lc 1e110219ff 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>
2026-05-24 20:05:56 +08:00

574 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 }