#!/usr/bin/env node /** * 口播视频生产编排器 * * 流程: 视频 → ASR转写 → 用户编辑字幕 → 裁剪组装 → 封面生成 → 输出表格 * * 用法: * # 单视频 * node 口播_pipeline.js init --video <路径> --name <标题> [--account <账号>] * node 口播_pipeline.js assemble --manifest [--topic-a <封面标题>] [--forward-copy <转发文案>] [--hashtags <话题>] * * # 批量 * node 口播_pipeline.js batch-init --file [--account <账号>] * node 口播_pipeline.js batch-assemble --file --row * node 口播_pipeline.js export --file [--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 [--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 [--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 --row ') 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 [--topic-a <封面标题>] [--forward-copy <转发文案>] [--hashtags <话题>]') console.log('') console.log('批量:') console.log(' batch-init --file [--account <账号>]') console.log(' batch-assemble --file --row ') console.log(' export --file [--format csv|xlsx]') console.log('') console.log('批量 CSV 格式: 视频路径,选题,转发文案带话题,账号') } } if (require.main === module) main() module.exports = { cmdInit, cmdAssemble, cmdBatchInit, cmdBatchAssemble, cmdExport }