在同步到本地剪映后新增轨道合并功能,将因降级添加导致的多条同类型轨道合并为单条。同时修复音频和字幕添加时可能跨场景重叠的问题,将 end 裁剪到 slot 边界。
487 lines
18 KiB
JavaScript
487 lines
18 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* CapCut 成片组装脚本 — 编排器
|
||
*
|
||
* 将图片/视频素材通过 CapCut Mate API 组装为草稿,同步到本地剪映。
|
||
*
|
||
* 模块结构:
|
||
* capcut_assemble.js ← 编排器(本文件)+ CLI + 上传 + 同步
|
||
* lib/capcut-api.js ← 配置、API 封装、工具函数
|
||
* lib/capcut-timeline.js ← 时间线构建 + 视频调整策略
|
||
* lib/capcut-tracks.js ← 所有轨道操作(图片/视频/音频/字幕/特效)
|
||
*
|
||
* 用法:
|
||
* node capcut_assemble.js --input ./output/batch_xxx [选项]
|
||
*/
|
||
|
||
const path = require('path')
|
||
const fs = require('fs')
|
||
|
||
const { US, parseArgs, getResolution, getAudioDurationSec } = require('./lib/capcut-api')
|
||
const { buildTimeline, adjustVideoSpeed } = require('./lib/capcut-timeline')
|
||
const {
|
||
loadAccountConfig, loadSubtitleStyle,
|
||
loadKenBurns, loadTransitions,
|
||
addImages, addVideos, addSlotsLocally,
|
||
addVoiceover, addBGM,
|
||
addSubtitles,
|
||
consolidateTracks,
|
||
addEffects, addFilter,
|
||
} = require('./lib/capcut-tracks')
|
||
const { saveManifest } = require('./lib/pipeline-utils')
|
||
const { syncDraft, registerDraft } = require('./sync-to-jianying')
|
||
|
||
// ============================================================================
|
||
// OSS 上传
|
||
// ============================================================================
|
||
|
||
const ossUpload = require(path.join(__dirname, 'oss-upload'))
|
||
|
||
async function uploadToOSS(filePath) {
|
||
const { url } = await ossUpload.uploadFile(filePath)
|
||
return url
|
||
}
|
||
|
||
async function batchUploadToOSS(inputDir, files, concurrency = 3) {
|
||
const urls = {}
|
||
const queue = [...files]
|
||
const workers = Array(Math.min(concurrency, queue.length)).fill(null).map(async () => {
|
||
while (queue.length > 0) {
|
||
const file = queue.shift()
|
||
if (!file) break
|
||
const filePath = path.join(inputDir, file)
|
||
if (!fs.existsSync(filePath)) continue
|
||
try {
|
||
urls[file] = await uploadToOSS(filePath)
|
||
console.log(` 上传: ${file} -> OK`)
|
||
} catch (err) {
|
||
console.error(` 上传失败: ${file} - ${err.message}`)
|
||
}
|
||
}
|
||
})
|
||
await Promise.all(workers)
|
||
return urls
|
||
}
|
||
|
||
async function batchUploadAudio(inputDir, items) {
|
||
const urls = {}
|
||
for (const item of items) {
|
||
// 处理主音频
|
||
if (item.audio && !item.audio.startsWith('http')) {
|
||
if (!urls[item.audio]) {
|
||
const filePath = path.isAbsolute(item.audio)
|
||
? item.audio
|
||
: path.resolve(inputDir, item.audio)
|
||
if (fs.existsSync(filePath)) {
|
||
try {
|
||
urls[item.audio] = await uploadToOSS(filePath)
|
||
console.log(` 上传: ${path.basename(filePath)} -> OK`)
|
||
} catch (err) {
|
||
console.error(` 上传失败: ${path.basename(filePath)} - ${err.message}`)
|
||
}
|
||
}
|
||
}
|
||
} else if (item.audio) {
|
||
urls[item.audio] = item.audio
|
||
}
|
||
// 处理分段音频
|
||
if (item.segments && item.segments.length > 0) {
|
||
for (const seg of item.segments) {
|
||
if (!seg.audio || seg.error) continue
|
||
if (urls[seg.audio]) continue
|
||
const filePath = path.isAbsolute(seg.audio)
|
||
? seg.audio
|
||
: path.resolve(inputDir, seg.audio)
|
||
if (!fs.existsSync(filePath)) {
|
||
console.error(` 音频文件不存在: ${filePath}`)
|
||
continue
|
||
}
|
||
try {
|
||
urls[seg.audio] = await uploadToOSS(filePath)
|
||
console.log(` 上传: ${path.basename(filePath)} -> OK`)
|
||
} catch (err) {
|
||
console.error(` 上传失败: ${path.basename(filePath)} - ${err.message}`)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return urls
|
||
}
|
||
|
||
// ============================================================================
|
||
// 同步到本地剪映
|
||
// ============================================================================
|
||
|
||
async function syncToLocalJianying(draftUrl, draftId, totalDurationUs) {
|
||
await syncDraft(draftUrl, { name: draftId })
|
||
registerDraft(draftId, draftId, totalDurationUs)
|
||
}
|
||
|
||
// ============================================================================
|
||
// 主流程
|
||
// ============================================================================
|
||
|
||
async function assemble(args) {
|
||
const {
|
||
input,
|
||
manifest: manifestPath,
|
||
mode = 'images',
|
||
subtitles = 'true',
|
||
splitCaptions = 'true',
|
||
voiceover = 'true',
|
||
bgm,
|
||
effects: effectsStr,
|
||
filter: filterStr,
|
||
format = '9:16',
|
||
apiKey = '',
|
||
animation = '轻微放大',
|
||
} = args
|
||
|
||
if (!input) throw new Error('缺少 --input 参数')
|
||
|
||
const inputDir = path.resolve(input)
|
||
const manifestFile = manifestPath
|
||
? path.resolve(manifestPath)
|
||
: path.join(inputDir, 'manifest.json')
|
||
|
||
if (!fs.existsSync(manifestFile)) {
|
||
throw new Error(`找不到 manifest.json: ${manifestFile}`)
|
||
}
|
||
|
||
const manifest = JSON.parse(fs.readFileSync(manifestFile, 'utf-8'))
|
||
|
||
// 从 account.json 自动继承 effects / filter
|
||
let finalEffects = effectsStr
|
||
let finalFilter = filterStr
|
||
if (!finalEffects || !finalFilter) {
|
||
const accountData = loadAccountConfig(manifest)
|
||
if (!finalEffects && accountData.capcut?.effects?.length) {
|
||
finalEffects = accountData.capcut.effects.join(',')
|
||
}
|
||
if (!finalFilter && accountData.capcut?.filter) {
|
||
finalFilter = accountData.capcut.filter
|
||
}
|
||
}
|
||
|
||
const { width, height } = getResolution(format)
|
||
|
||
const items = manifest.items.filter(item => {
|
||
if (item.url) return true
|
||
if (item.video) return true
|
||
const filePath = path.join(inputDir, item.file)
|
||
return fs.existsSync(filePath)
|
||
})
|
||
|
||
if (items.length === 0) throw new Error('没有可用的素材文件')
|
||
|
||
// 测量实际时长
|
||
let audioMeasured = 0, videoMeasured = 0
|
||
for (const item of items) {
|
||
if (item.audio && !item.audio.startsWith('http')) {
|
||
const audioPath = path.isAbsolute(item.audio)
|
||
? item.audio
|
||
: path.resolve(inputDir, item.audio)
|
||
if (fs.existsSync(audioPath)) {
|
||
const actualDur = await getAudioDurationSec(audioPath)
|
||
if (actualDur != null) { item.audioDuration = actualDur; audioMeasured++ }
|
||
}
|
||
}
|
||
if (item.video) {
|
||
const videoPath = path.isAbsolute(item.video)
|
||
? item.video
|
||
: path.resolve(inputDir, item.video)
|
||
if (fs.existsSync(videoPath)) {
|
||
const actualDur = await getAudioDurationSec(videoPath)
|
||
if (actualDur != null) { item.videoDuration = actualDur; videoMeasured++ }
|
||
}
|
||
}
|
||
}
|
||
if (audioMeasured > 0 || videoMeasured > 0) {
|
||
console.log(` 实际时长测量: 音频 ${audioMeasured} 个, 视频 ${videoMeasured} 个`)
|
||
}
|
||
|
||
const timeline = buildTimeline(items)
|
||
const totalDurationUs = timeline.length > 0 ? timeline[timeline.length - 1].end : 0
|
||
const hasTTS = items.some(item => item.audio && item.audioDuration != null)
|
||
|
||
// 时间轴诊断
|
||
for (let i = 0; i < items.length; i++) {
|
||
const item = items[i]
|
||
const tl = timeline[i]
|
||
if (tl.skip) { console.log(` [${i + 1}] 跳过(无音频)`); continue }
|
||
const audioDur = item.audioDuration || 0
|
||
const slotDur = tl.duration / US
|
||
const diff = slotDur - audioDur
|
||
const videoDur = (item.videoDuration || 0)
|
||
const stratInfo = tl.strategy && tl.strategy !== 'none' ? ` 策略=${tl.strategy}` : ''
|
||
const marker = Math.abs(diff) > 0.05 ? ' ⚠️ 不对齐' : ''
|
||
console.log(` [${i + 1}] 画面=${slotDur.toFixed(2)}s 音频=${audioDur.toFixed(2)}s 视频=${videoDur.toFixed(2)}s${stratInfo}${marker}`)
|
||
}
|
||
|
||
const transitionConfig = loadTransitions(manifest)
|
||
|
||
console.log(`\nCapCut 成片组装`)
|
||
console.log(` 模式: ${mode} 画幅: ${format} (${width}x${height})`)
|
||
console.log(` 时间线: ${hasTTS ? 'TTS音频驱动' : '视频原始时长'} 总时长: ${(totalDurationUs / US).toFixed(1)}s`)
|
||
console.log(` 字幕: ${subtitles} 配音: ${voiceover} 动画: ${animation}`)
|
||
if (finalEffects) console.log(` 特效: ${finalEffects}`)
|
||
if (finalFilter) console.log(` 滤镜: ${finalFilter}`)
|
||
console.log(` 素材: ${items.length} 个可用\n`)
|
||
|
||
const steps = []
|
||
if (mode === 'images') steps.push('upload')
|
||
steps.push('draft', 'materials', 'kenburns', 'audio_oss', 'voiceover', 'audio', 'subtitles', 'effects', 'filter', 'save', 'sync', 'consolidate')
|
||
const totalSteps = steps.length
|
||
let step = 0
|
||
|
||
// -- 上传图片到 OSS --
|
||
let imgUrls = {}
|
||
if (mode === 'images') {
|
||
const needUpload = []
|
||
for (const item of items) {
|
||
if (item.url && item.url.startsWith('http')) {
|
||
imgUrls[item.file] = item.url
|
||
} else {
|
||
needUpload.push(item.file)
|
||
}
|
||
}
|
||
if (needUpload.length > 0) {
|
||
step++; console.log(`[${step}/${totalSteps}] 上传图片到 OSS (${needUpload.length} 张需上传, ${Object.keys(imgUrls).length} 张已有URL)...`)
|
||
const uploaded = await batchUploadToOSS(inputDir, needUpload)
|
||
imgUrls = { ...imgUrls, ...uploaded }
|
||
} else {
|
||
step++; console.log(`[${step}/${totalSteps}] 所有图片已有 URL,跳过上传`)
|
||
}
|
||
if (Object.keys(imgUrls).length === 0) throw new Error('所有图片上传失败')
|
||
console.log(` 成功: ${Object.keys(imgUrls).length}/${items.length}\n`)
|
||
}
|
||
|
||
// -- 创建草稿 --
|
||
step++; console.log(`[${step}/${totalSteps}] 创建草稿...`)
|
||
const draftRes = await require('./lib/capcut-api').api('create_draft', { width, height })
|
||
const draftUrl = draftRes.draft_url
|
||
const draftId = new URL(draftUrl).searchParams.get('draft_id')
|
||
console.log(` draft_id: ${draftId}\n`)
|
||
|
||
// -- 导入素材 --
|
||
step++; console.log(`[${step}/${totalSteps}] 导入素材...`)
|
||
let imageSegmentIds = []
|
||
if (mode === 'images') {
|
||
imageSegmentIds = await addImages(draftUrl, items, imgUrls, timeline, width, height, animation, transitionConfig)
|
||
} else {
|
||
// 视频模式:调整 → 上传 → 添加
|
||
let adjustedCount = 0
|
||
for (let i = 0; i < items.length; i++) {
|
||
const item = items[i]
|
||
const tl = timeline[i]
|
||
if (tl.strategy && tl.strategy !== 'none' && item.video) {
|
||
const videoPath = path.resolve(inputDir, item.video)
|
||
const audioDur = tl.duration / US
|
||
const adjustedPath = await adjustVideoSpeed(videoPath, audioDur, tl.strategy, tl.speed, tl.freezeExtra || 0)
|
||
if (adjustedPath !== videoPath) {
|
||
item.video = path.relative(inputDir, adjustedPath)
|
||
item.videoDuration = audioDur
|
||
adjustedCount++
|
||
}
|
||
}
|
||
}
|
||
if (adjustedCount > 0) {
|
||
console.log(` 视频调整: ${adjustedCount}/${items.length} 个`)
|
||
}
|
||
|
||
const missingUrl = items.filter(it => it.video && !it.videoUrl)
|
||
if (missingUrl.length > 0) {
|
||
console.log(` 上传 ${missingUrl.length} 个视频到 OSS...`)
|
||
for (const item of missingUrl) {
|
||
const videoPath = path.resolve(inputDir, item.video)
|
||
try {
|
||
const url = await uploadToOSS(videoPath)
|
||
item.videoUrl = url
|
||
if (manifestFile) {
|
||
try {
|
||
const m = JSON.parse(fs.readFileSync(manifestFile, 'utf-8'))
|
||
const mi = m.items.find(i => i.id === item.id)
|
||
if (mi) { mi.videoUrl = url; saveManifest(manifestFile, m) }
|
||
} catch (_) {}
|
||
}
|
||
} catch (err) {
|
||
console.log(` 视频上传失败: ${err.message}`)
|
||
}
|
||
}
|
||
}
|
||
const segmentIds = await addVideos(draftUrl, inputDir, items, timeline, width, height, transitionConfig)
|
||
// 将 segment_ids 附加到 items,供后续 addSlotsLocally 使用
|
||
if (segmentIds && segmentIds.length > 0) {
|
||
items.forEach((item, i) => { item._segmentId = segmentIds[i] || null })
|
||
}
|
||
}
|
||
|
||
// -- Ken Burns --
|
||
if (mode === 'images' && imageSegmentIds.length > 0) {
|
||
step++; console.log(`[${step}/${totalSteps}] 添加 Ken Burns 镜头动画...`)
|
||
await addKenBurns(draftUrl, imageSegmentIds, items, timeline, manifest)
|
||
}
|
||
|
||
// -- 上传 TTS 音频到 OSS --
|
||
let audioUrls = {}
|
||
if (voiceover === 'true' && hasTTS) {
|
||
step++; console.log(`[${step}/${totalSteps}] 上传 TTS 音频到 OSS...`)
|
||
try {
|
||
audioUrls = await batchUploadAudio(inputDir, items)
|
||
console.log(` 成功: ${Object.keys(audioUrls).length} 段音频\n`)
|
||
if (Object.keys(audioUrls).length > 0 && manifestFile) {
|
||
let changed = false
|
||
for (const item of manifest.items) {
|
||
if (item.audio && audioUrls[item.audio]) {
|
||
item.audio = audioUrls[item.audio]
|
||
changed = true
|
||
}
|
||
}
|
||
if (changed) saveManifest(manifestFile, manifest)
|
||
}
|
||
} catch (err) {
|
||
console.log(` OSS 上传失败,将尝试本地路径: ${err.message}\n`)
|
||
}
|
||
}
|
||
|
||
// -- 添加 TTS 配音 --
|
||
step++; console.log(`[${step}/${totalSteps}] 添加 TTS 配音...`)
|
||
if (voiceover === 'true' && hasTTS) {
|
||
await addVoiceover(draftUrl, inputDir, items, timeline, audioUrls)
|
||
} else {
|
||
console.log(' 跳过(无 TTS 音频或未启用)')
|
||
}
|
||
|
||
// -- 添加 BGM --
|
||
step++; console.log(`[${step}/${totalSteps}] 添加背景音乐...`)
|
||
if (bgm) {
|
||
await addBGM(draftUrl, bgm, totalDurationUs)
|
||
} else {
|
||
console.log(' 跳过(未指定 --bgm)')
|
||
}
|
||
|
||
// -- 字幕风格 --
|
||
const subtitleStyle = loadSubtitleStyle(manifest)
|
||
if (Object.keys(subtitleStyle).length > 0) {
|
||
console.log(` 字幕风格: ${subtitleStyle.font || '默认'} ${subtitleStyle.inAnimation ? subtitleStyle.inAnimation + '→' + subtitleStyle.outAnimation : ''}`)
|
||
}
|
||
|
||
// -- 添加字幕 --
|
||
step++; console.log(`[${step}/${totalSteps}] 添加字幕...`)
|
||
if (subtitles === 'true' && items.some(i => i.script || i.text)) {
|
||
await addSubtitles(draftUrl, items, timeline, subtitleStyle, splitCaptions === 'true')
|
||
} else {
|
||
console.log(' 跳过')
|
||
}
|
||
|
||
// -- 特效 -- // -- 特效 --
|
||
step++; console.log(`[${step}/${totalSteps}] 添加特效...`)
|
||
if (finalEffects) {
|
||
try {
|
||
await addEffects(draftUrl, finalEffects, totalDurationUs)
|
||
} catch (e) {
|
||
console.log(` 特效跳过: ${e.message}`)
|
||
}
|
||
} else {
|
||
console.log(' 跳过(未配置特效)')
|
||
}
|
||
|
||
// -- 滤镜 --
|
||
step++; console.log(`[${step}/${totalSteps}] 添加滤镜...`)
|
||
if (finalFilter) {
|
||
try {
|
||
await addFilter(draftUrl, finalFilter, totalDurationUs)
|
||
} catch (e) {
|
||
console.log(` 滤镜跳过: ${e.message}`)
|
||
}
|
||
} else {
|
||
console.log(' 跳过(未配置滤镜)')
|
||
}
|
||
|
||
// -- 保存草稿 --
|
||
step++; console.log(`[${step}/${totalSteps}] 保存草稿...`)
|
||
await require('./lib/capcut-api').api('save_draft', { draft_url: draftUrl })
|
||
console.log(' 已保存\n')
|
||
|
||
// -- 同步到本地剪映 --
|
||
step++; console.log(`[${step}/${totalSteps}] 同步到本地剪映...`)
|
||
await syncToLocalJianying(draftUrl, draftId, totalDurationUs)
|
||
console.log(' 同步完成\n')
|
||
|
||
// -- 合并同类型轨道(TTS 逐条降级时每条独占一个轨道)--
|
||
step++; console.log(`[${step}/${totalSteps}] 合并同类型轨道...`)
|
||
consolidateTracks(draftId)
|
||
|
||
// -- 视频轨道 slot 写入(在 syncToLocalJianying 之后执行,此时本地草稿文件已存在)--
|
||
if (mode !== 'images') {
|
||
step++; console.log(`[${step}/${totalSteps}] 写入视频轨道时间线...`)
|
||
await addSlotsLocally(draftUrl, items, timeline, null, { draftId })
|
||
console.log(' 视频轨道写入完成\n')
|
||
}
|
||
|
||
// -- 云渲染(可选)--
|
||
if (apiKey) {
|
||
console.log('提交云渲染...')
|
||
await require('./lib/capcut-api').api('gen_video', { draft_url: draftUrl, apiKey })
|
||
console.log('渲染已提交,使用 gen_video_status 查询进度')
|
||
}
|
||
|
||
console.log(`\n成片组装完成`)
|
||
console.log(` 草稿ID: ${draftId}`)
|
||
console.log(` 总时长: ${(totalDurationUs / US).toFixed(1)}s`)
|
||
console.log(` 素材数: ${items.length}`)
|
||
console.log(` 时间线: ${hasTTS ? 'TTS音频驱动' : '视频原始时长'}`)
|
||
if (mode === 'videos' && subtitles === 'false') {
|
||
console.log(`\n >> 视频模式未加字幕,请在剪映中打开草稿 → 识别字幕 → 语音识别生成\n`)
|
||
}
|
||
}
|
||
|
||
// ============================================================================
|
||
// CLI 入口
|
||
// ============================================================================
|
||
|
||
async function main() {
|
||
const args = parseArgs(process.argv.slice(2))
|
||
|
||
if (!args.input) {
|
||
console.log('用法: node capcut_assemble.js --input <目录> [选项]')
|
||
console.log('')
|
||
console.log('必填:')
|
||
console.log(' --input <dir> 素材目录(含 manifest.json)')
|
||
console.log('')
|
||
console.log('选项:')
|
||
console.log(' --mode images|videos 素材类型(默认 images)')
|
||
console.log(' --format 9:16 画幅比例')
|
||
console.log(' --voiceover true|false 是否添加TTS配音轨道(默认 true)')
|
||
console.log(' --subtitles true|false 是否添加字幕(默认 true)')
|
||
console.log(' --split-captions true|false 分句字幕模式(默认 true,按标点切分)')
|
||
console.log(' --bgm <url> 背景音乐 URL')
|
||
console.log(' --effects "名称1,名称2" 特效名称(逗号分隔)')
|
||
console.log(' --filter "名称:强度" 滤镜(强度 0-100)')
|
||
console.log(' --apiKey <key> 云渲染 API Key(可选)')
|
||
console.log(' --manifest <path> manifest.json 路径')
|
||
console.log('')
|
||
console.log('时间线规则:')
|
||
console.log(' 图片模式: TTS 音频时长 = 画面时长,无音频则跳过')
|
||
console.log(' 视频模式: TTS 为主轴,视频通过以下策略适配:')
|
||
console.log(' 视频比音频长 → 加速(≤2x) 或 裁剪(>2x)')
|
||
console.log(' 视频比音频短 → 放缓(≥0.5x) 或 画面停顿(<0.5x)')
|
||
console.log(' 所有策略失败 → 兜底截断')
|
||
console.log('')
|
||
console.log('配置:')
|
||
console.log(' 请运行 node setup.js 生成配置')
|
||
process.exit(0)
|
||
}
|
||
|
||
await assemble(args)
|
||
}
|
||
|
||
if (require.main === module) {
|
||
main().catch(err => {
|
||
console.error(`\n错误: ${err.message}`)
|
||
process.exit(1)
|
||
})
|
||
}
|
||
|
||
module.exports = { assemble }
|