#!/usr/bin/env node /** * 口播批量剪辑 — CapCut 草稿组装 v2.1 * * 策略: * 1. ffmpeg 按 edit 字幕时间轴裁剪视频片段 + concat 拼接 + 1.1x 倍速 → 单文件 * 2. 上传裁剪后视频到 OSS → 剪映里显示为一条连续轨道 * 3. 长字幕按标点拆分为 2-3s 短段,避免长时间遮挡画面 * * 注: 尝试过 slot 级 source_timerange 方案(v3),但 CapCut Mate API 不支持, * 本地改写 draft_content.json 会被云同步覆盖/删除。故回到 ffmpeg 预裁剪。 * * 用法: * node 口播_assemble.js --video <视频路径> --transcript <_edit.txt路径> [--name <草稿名>] [--speed 1.1] */ const path = require('path') const fs = require('fs') const { execSync, execFileSync } = require('child_process') const { api, US, getConfig } = require('./lib/capcut-api') const { addVideos, addSubtitles } = require('./lib/capcut-tracks') const { syncDraft, registerDraft } = require('./sync-to-jianying') const ossUpload = require('./oss-upload') // ============================================================================ // 解析参数 // ============================================================================ function parseArgs() { const args = process.argv.slice(2) const result = {} for (let i = 0; i < args.length; i++) { if (args[i].startsWith('--')) { result[args[i].slice(2)] = args[i + 1] || true i++ } } return result } // ============================================================================ // 解析字幕文件 → [{begin_ms, end_ms, text}] // ============================================================================ function parseTranscript(filePath) { const lines = fs.readFileSync(filePath, 'utf-8').trim().split('\n') const sentences = [] const re = /\[(\d+):(\d+)\.(\d+)\s*-->\s*(\d+):(\d+)\.(\d+)\]\s*(.*)/ for (const line of lines) { const m = line.match(re) if (!m) continue const [, m1, s1, ms1, m2, s2, ms2, text] = m const begin_ms = (parseInt(m1) * 60 + parseInt(s1)) * 1000 + parseInt(ms1.padEnd(3, '0').slice(0, 3)) const end_ms = (parseInt(m2) * 60 + parseInt(s2)) * 1000 + parseInt(ms2.padEnd(3, '0').slice(0, 3)) if (text.trim()) { sentences.push({ begin_ms, end_ms, text: text.trim() }) } } return sentences } // ============================================================================ // 字幕拆分:长句按标点拆成 2-3s 短段 // ============================================================================ function splitSubtitleChunks(text, totalDurationMs) { if (!text) return [] const rawParts = text.split(/(?<=[,。!?,\.!\?、;:])/).filter(Boolean) if (rawParts.length === 0) return [{ text, ratio: 1 }] const charCounts = rawParts.map(p => p.length) const totalChars = charCounts.reduce((a, b) => a + b, 0) if (totalChars === 0) return [{ text, ratio: 1 }] const merged = [] let buffer = { text: '', chars: 0 } for (let i = 0; i < rawParts.length; i++) { buffer.text += rawParts[i] buffer.chars += charCounts[i] const estDuration = (buffer.chars / totalChars) * totalDurationMs if (estDuration >= 1500 || i === rawParts.length - 1) { merged.push({ text: buffer.text, ratio: buffer.chars / totalChars, }) buffer = { text: '', chars: 0 } } } if (buffer.chars > 0) { if (merged.length > 0) { merged[merged.length - 1].text += buffer.text merged[merged.length - 1].ratio += buffer.chars / totalChars } else { merged.push({ text: buffer.text, ratio: buffer.chars / totalChars }) } } return merged.length > 0 ? merged : [{ text, ratio: 1 }] } // ============================================================================ // 构建 ffmpeg trim+concat+speed 命令 // ============================================================================ function buildTrimCommand(inputPath, outputPath, segments, speed) { const ffmpeg = '/usr/local/bin/ffmpeg' const filters = [] const concatParts = [] const n = segments.length for (let i = 0; i < n; i++) { const seg = segments[i] const t0 = seg.begin_ms / 1000 const t1 = seg.end_ms / 1000 filters.push(`[0:v]trim=${t0}:${t1},setpts=PTS-STARTPTS[vt${i}];`) filters.push(`[0:a]atrim=${t0}:${t1},asetpts=PTS-STARTPTS[at${i}];`) concatParts.push(`[vt${i}][at${i}]`) } const concatInputs = concatParts.join('') const filterComplex = ( filters.join('') + `${concatInputs}concat=n=${n}:v=1:a=1[vraw][araw];` + `[vraw]setpts=${(1/speed).toFixed(6)}*PTS[v];` + `[araw]atempo=${speed}[a]` ) return [ ffmpeg, '-i', inputPath, '-filter_complex', filterComplex, '-map', '[v]', '-map', '[a]', '-c:v', 'libx264', '-crf', '23', '-preset', 'fast', '-c:a', 'aac', '-b:a', '128k', outputPath, '-y', '-loglevel', 'error', ] } // ============================================================================ // 获取视频时长(毫秒) // ============================================================================ function getVideoDurationMs(videoPath) { try { const out = execSync( `/usr/local/bin/ffmpeg -i "${videoPath.replace(/"/g, '\\"')}" 2>&1 | grep Duration || true` ).toString() const m = out.match(/Duration:\s*(\d+):(\d+):(\d+)\.(\d+)/) if (!m) throw new Error('Duration not found') return (parseInt(m[1]) * 3600 + parseInt(m[2]) * 60 + parseInt(m[3])) * 1000 + Math.round(parseInt(m[4].padEnd(3, '0').slice(0, 3))) } catch (e) { throw new Error(`无法读取视频时长: ${e.message}`) } } // ============================================================================ // 获取视频分辨率 // ============================================================================ function getVideoResolution(videoPath) { try { const out = execSync( `/usr/local/bin/ffmpeg -i "${videoPath.replace(/"/g, '\\"')}" 2>&1 | grep 'Video:' || true` ).toString() const m = out.match(/(\d{3,4})x(\d{3,4})/) if (m) return { width: parseInt(m[1]), height: parseInt(m[2]) } } catch {} return { width: 1080, height: 1920 } } // ============================================================================ // 获取下一个全局序号 // ============================================================================ function getNextGlobalIndex() { const config = getConfig() const draftDir = config.jianyingDraftPath if (!fs.existsSync(draftDir)) return 1 const folders = fs.readdirSync(draftDir) let maxIdx = 0 for (const f of folders) { const m = f.match(/_(\d{2})_/) if (m) maxIdx = Math.max(maxIdx, parseInt(m[1])) } return maxIdx + 1 } // ============================================================================ // 主流程 // ============================================================================ async function main() { const args = parseArgs() if (!args.video || !args.transcript) { console.error('用法: node 口播_assemble.js --video <视频> --transcript <字幕文件> [--name <草稿名>] [--speed 1.1]') process.exit(1) } const videoPath = path.resolve(args.video) const transcriptPath = path.resolve(args.transcript) const speed = parseFloat(args.speed) || 1.1 if (!fs.existsSync(videoPath)) { console.error(`视频不存在: ${videoPath}`); process.exit(1) } if (!fs.existsSync(transcriptPath)) { console.error(`字幕文件不存在: ${transcriptPath}`); process.exit(1) } // 账号字幕样式 const accountDir = path.resolve(__dirname, '..', '..', '..', 'accounts', '口播批量剪辑') const accountConfig = fs.existsSync(path.join(accountDir, 'account.json')) ? JSON.parse(fs.readFileSync(path.join(accountDir, 'account.json'), 'utf-8')) : {} const subtitleStyle = accountConfig.capcut?.subtitleStyle || {} // 1. 解析字幕 console.log('[1/6] 解析字幕...') const sentences = parseTranscript(transcriptPath) console.log(` ${sentences.length} 句字幕`) if (sentences.length === 0) { console.error('字幕文件为空或格式不对') process.exit(1) } console.log(' --- 保留时间段(已删除气口/停顿)---') for (let i = 0; i < sentences.length; i++) { const s = sentences[i] const segDur = (s.end_ms - s.begin_ms) / 1000 console.log(` [${i+1}] ${(s.begin_ms/1000).toFixed(2)}s → ${(s.end_ms/1000).toFixed(2)}s (${segDur.toFixed(1)}s) ${s.text.slice(0,20)}...`) } // 2. 读取视频信息 console.log('[2/6] 读取视频信息...') const { width: vw, height: vh } = getVideoResolution(videoPath) const width = 1080 const height = 1920 console.log(` 原始分辨率: ${vw}x${vh}`) // 3. 压缩到1080p + 按字幕裁剪 + 倍速 console.log(`[3/6] 裁剪视频(${sentences.length}段)+ ${speed}x 倍速...`) // 先压缩到 1080p const tmpCompressed = `/tmp/口播_compressed_${Date.now()}.mp4` execSync( `/usr/local/bin/ffmpeg -i "${videoPath.replace(/"/g, '\\"')}" -vf "scale=1920:1080:force_original_aspect_ratio=decrease" -c:v libx264 -crf 23 -preset fast -c:a aac -b:a 128k "${tmpCompressed}" -y -loglevel error`, { stdio: 'pipe' } ) console.log(` 压缩后: ${(fs.statSync(tmpCompressed).size / 1024 / 1024).toFixed(1)} MB`) // trim + concat + speed const tmpTrimmed = `/tmp/口播_trimmed_${Date.now()}.mp4` const trimArgs = buildTrimCommand(tmpCompressed, tmpTrimmed, sentences, speed) execFileSync(trimArgs[0], trimArgs.slice(1), { stdio: 'pipe', maxBuffer: 10 * 1024 * 1024 }) console.log(` 剪辑后: ${(fs.statSync(tmpTrimmed).size / 1024 / 1024).toFixed(1)} MB`) const trimmedDurationMs = getVideoDurationMs(tmpTrimmed) const totalSourceMs = sentences.reduce((a, s) => a + (s.end_ms - s.begin_ms), 0) console.log(` 剪辑后时长: ${(trimmedDurationMs / 1000).toFixed(1)}s (原 ${totalSourceMs / 1000}s ÷ ${speed})`) fs.unlinkSync(tmpCompressed) // 4. 上传到 OSS console.log('[4/6] 上传到 OSS...') const { url: videoUrl } = await ossUpload.uploadFile(tmpTrimmed) fs.unlinkSync(tmpTrimmed) console.log(` ${videoUrl.slice(0, 60)}...`) // 5. 创建草稿 + 添加视频 + 字幕 console.log('[5/6] 创建草稿 + 添加视频 + 字幕...') const draftRes = await api('create_draft', { width, height }) const draftUrl = draftRes.draft_url const draftId = new URL(draftUrl).searchParams.get('draft_id') console.log(` draft_id: ${draftId}`) // 单段视频(已裁剪拼接+倍速) const durationUs = trimmedDurationMs * 1000 const videoItems = [{ videoUrl, id: 'main' }] const videoTimeline = [{ start: 0, end: durationUs, duration: durationUs }] await addVideos(draftUrl, path.dirname(videoPath), videoItems, videoTimeline, width, height) // 字幕 timeline(按倍速后时间轴,逐句拆分短段) const subtitleItems = [] const subtitleTimeline = [] let cumUs = 0 for (const s of sentences) { const segDurationMs = (s.end_ms - s.begin_ms) / speed const segDurationUs = Math.round(segDurationMs * 1000) const chunks = splitSubtitleChunks(s.text, segDurationMs) let chunkCumMs = 0 for (const chunk of chunks) { const chunkDurationMs = chunk.ratio * segDurationMs const chunkDurationUs = Math.round(chunkDurationMs * 1000) const chunkStartUs = cumUs + Math.round(chunkCumMs * 1000) subtitleItems.push({ text: chunk.text }) subtitleTimeline.push({ start: chunkStartUs, end: chunkStartUs + chunkDurationUs, duration: chunkDurationUs, }) chunkCumMs += chunkDurationMs } cumUs += segDurationUs } console.log(` 字幕拆分为 ${subtitleItems.length} 段`) await addSubtitles(draftUrl, subtitleItems, subtitleTimeline, subtitleStyle, false) // 6. 同步到本地剪映 console.log('[6/6] 同步到剪映...') const today = new Date() const mmdd = String(today.getMonth() + 1).padStart(2, '0') + String(today.getDate()).padStart(2, '0') const idx = String(getNextGlobalIndex()).padStart(2, '0') const shortTitle = args.name || path.basename(videoPath, path.extname(videoPath)).replace(/[\s()]/g, '').slice(0, 6) const draftName = `口播批量剪辑_${mmdd}_${idx}_${shortTitle}` await syncDraft(draftUrl, { name: draftName }) registerDraft(draftId, draftName, durationUs) // 重命名草稿文件夹(Mac 版剪映显示名 = 文件夹名) const config = getConfig() const oldDir = path.join(config.jianyingDraftPath, draftId) const newDir = path.join(config.jianyingDraftPath, draftName) if (fs.existsSync(oldDir) && !fs.existsSync(newDir)) { fs.renameSync(oldDir, newDir) console.log(` 草稿文件夹已重命名: ${draftName}`) } console.log(`\n完成!`) console.log(` 草稿名称: ${draftName}`) console.log(` 草稿地址: ${draftUrl}`) console.log(` 倍速: ${speed}x | 视频: 1段连续 | 字幕: ${subtitleItems.length}段`) console.log(`\n下一步: 封面生成`) } main().catch(err => { console.error('组装失败:', err.message) process.exit(1) })