feat(capcut): 合并同类型轨道并优化音频/字幕边界裁剪

在同步到本地剪映后新增轨道合并功能,将因降级添加导致的多条同类型轨道合并为单条。同时修复音频和字幕添加时可能跨场景重叠的问题,将 end 裁剪到 slot 边界。
This commit is contained in:
2026-05-07 00:17:17 +08:00
parent 73b0860fe5
commit ce54a65abb
2 changed files with 88 additions and 11 deletions

View File

@@ -25,6 +25,7 @@ const {
addImages, addVideos, addSlotsLocally,
addVoiceover, addBGM,
addSubtitles,
consolidateTracks,
addEffects, addFilter,
} = require('./lib/capcut-tracks')
const { saveManifest } = require('./lib/pipeline-utils')
@@ -229,7 +230,7 @@ async function assemble(args) {
const steps = []
if (mode === 'images') steps.push('upload')
steps.push('draft', 'materials', 'kenburns', 'audio_oss', 'voiceover', 'audio', 'subtitles', 'effects', 'filter', 'save', 'sync')
steps.push('draft', 'materials', 'kenburns', 'audio_oss', 'voiceover', 'audio', 'subtitles', 'effects', 'filter', 'save', 'sync', 'consolidate')
const totalSteps = steps.length
let step = 0
@@ -407,6 +408,10 @@ async function assemble(args) {
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}] 写入视频轨道时间线...`)

View File

@@ -458,6 +458,74 @@ function generateUUID() {
})
}
// ============================================================================
// 本地轨道合并 — 将同一类型的多个轨道合并为一个
// 背景:远端 add_audios / add_captions 每次调用都会创建新轨道。
// 批量添加失败降级为逐条调用时,会导致每条 segment 独占一个轨道。
// 此函数在 sync 后读取本地 draft_content.json合并同类型轨道。
// ============================================================================
function consolidateTracks(draftId) {
const jianyingPath = getConfig().jianyingDraftPath
const draftPath = path.join(jianyingPath, draftId, 'draft_content.json')
if (!fs.existsSync(draftPath)) {
console.log(' 本地草稿不存在,跳过轨道合并')
return
}
let draft
try {
draft = JSON.parse(fs.readFileSync(draftPath, 'utf-8'))
} catch {
console.log(' draft_content.json 读取失败,跳过轨道合并')
return
}
const tracks = draft.tracks || []
for (const trackType of ['audio', 'text']) {
const sameTypeTracks = tracks.filter(t => t.type === trackType && t.segments && t.segments.length > 0)
if (sameTypeTracks.length <= 1) continue
// 以第一个轨道为主,将其他轨道的 segments 合并进来
const primary = sameTypeTracks[0]
for (let i = 1; i < sameTypeTracks.length; i++) {
primary.segments.push(...sameTypeTracks[i].segments)
sameTypeTracks[i].segments = []
}
// 去重:同 start 只保留第一个(批量失败→逐条降级时首段会重复)
const seen = new Set()
const deduped = []
for (const seg of primary.segments) {
const key = seg.target_timerange?.start ?? seg.start_time ?? null
if (key == null || seen.has(key)) continue
seen.add(key)
deduped.push(seg)
}
const dupCount = primary.segments.length - deduped.length
primary.segments = deduped
// 按 start 排序
primary.segments.sort((a, b) => {
const aStart = a.target_timerange?.start ?? a.start_time ?? 0
const bStart = b.target_timerange?.start ?? b.start_time ?? 0
return aStart - bStart
})
console.log(` 已合并 ${sameTypeTracks.length}${trackType} 轨道 → 1 条 (${primary.segments.length} 段)${dupCount > 0 ? ` 去重 ${dupCount}` : ''}`)
}
// 移除空轨道segments 为空的非 video 轨道)
draft.tracks = tracks.filter(t => {
if (t.type === 'video') return true
if (t.type === 'effect' || t.type === 'filter') return true
return t.segments && t.segments.length > 0
})
fs.writeFileSync(draftPath, JSON.stringify(draft, null, 2), 'utf-8')
triggerDirScan(path.dirname(draftPath))
}
const { execFile } = require('child_process')
function triggerDirScan(dir) {
@@ -484,33 +552,36 @@ async function addVoiceover(draftUrl, inputDir, items, timeline, audioUrls = {})
if (!item.audio) continue
if (item.segments && item.segments.length > 0) {
// 使用 segments 精确添加
// 使用 segments 精确添加end 裁剪到 slot 边界防止跨场景重叠
for (const seg of item.segments) {
if (!seg.audio || seg.error) continue
const audioUrl = seg.audio.startsWith('http')
? seg.audio
: (audioUrls[seg.audio] || path.resolve(inputDir, seg.audio))
const segDurUs = Math.round(seg.duration * US)
const segStartUs = tl.start + Math.round(seg.startOffset * US)
const segEndUs = Math.min(segStartUs + Math.round(seg.duration * US), tl.end)
const actualDurUs = segEndUs - segStartUs
segmentsFlat.push({
audio_url: audioUrl,
start: segStartUs,
end: segStartUs + segDurUs,
duration: segDurUs,
end: segEndUs,
duration: actualDurUs,
volume: 1.0,
})
}
} else {
// 降级:整段添加
// 降级:整段添加end 裁剪到 slot 边界
const audioUrl = item.audio.startsWith('http')
? item.audio
: (audioUrls[item.audio] || path.resolve(inputDir, item.audio))
const audioDurUs = item.audioDuration ? item.audioDuration * US : tl.duration
const endUs = Math.min(tl.start + audioDurUs, tl.end)
const actualDurUs = endUs - tl.start
segmentsFlat.push({
audio_url: audioUrl,
start: tl.start,
end: tl.start + audioDurUs,
duration: audioDurUs,
end: endUs,
duration: actualDurUs,
volume: 1.0,
})
}
@@ -598,15 +669,15 @@ async function addSubtitles(draftUrl, items, timeline, style = {}, split = false
const tl = timeline[i]
if (split && item.segments && item.segments.length > 0) {
// 精确字幕模式:使用 segments 实测时长,逐段添加字幕
// 精确字幕模式end 裁剪到 slot 边界防止跨场景重叠
for (const seg of item.segments) {
if (seg.error || !seg.text) continue
const segStartUs = tl.start + Math.round(seg.startOffset * US)
const segDurUs = Math.round(seg.duration * US)
const segEndUs = Math.min(segStartUs + Math.round(seg.duration * US), tl.end)
const cap = {
start: segStartUs,
end: segStartUs + segDurUs,
end: segEndUs,
text: seg.text,
}
@@ -819,6 +890,7 @@ module.exports = {
addKeywordOverlays,
addSlots,
addSlotsLocally,
consolidateTracks,
addEffects,
addFilter,
}