feat(capcut): 合并同类型轨道并优化音频/字幕边界裁剪
在同步到本地剪映后新增轨道合并功能,将因降级添加导致的多条同类型轨道合并为单条。同时修复音频和字幕添加时可能跨场景重叠的问题,将 end 裁剪到 slot 边界。
This commit is contained in:
@@ -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}] 写入视频轨道时间线...`)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user