- 新增 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>
348 lines
13 KiB
JavaScript
348 lines
13 KiB
JavaScript
#!/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)
|
||
})
|