Files
video-create/.claude/skills/video-from-script/scripts/口播_assemble.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

348 lines
13 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
/**
* 口播批量剪辑 — 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)
})