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,347 @@
#!/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)
})