feat(capcut): 合并同类型轨道并优化音频/字幕边界裁剪
在同步到本地剪映后新增轨道合并功能,将因降级添加导致的多条同类型轨道合并为单条。同时修复音频和字幕添加时可能跨场景重叠的问题,将 end 裁剪到 slot 边界。
This commit is contained in:
@@ -25,6 +25,7 @@ const {
|
|||||||
addImages, addVideos, addSlotsLocally,
|
addImages, addVideos, addSlotsLocally,
|
||||||
addVoiceover, addBGM,
|
addVoiceover, addBGM,
|
||||||
addSubtitles,
|
addSubtitles,
|
||||||
|
consolidateTracks,
|
||||||
addEffects, addFilter,
|
addEffects, addFilter,
|
||||||
} = require('./lib/capcut-tracks')
|
} = require('./lib/capcut-tracks')
|
||||||
const { saveManifest } = require('./lib/pipeline-utils')
|
const { saveManifest } = require('./lib/pipeline-utils')
|
||||||
@@ -229,7 +230,7 @@ async function assemble(args) {
|
|||||||
|
|
||||||
const steps = []
|
const steps = []
|
||||||
if (mode === 'images') steps.push('upload')
|
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
|
const totalSteps = steps.length
|
||||||
let step = 0
|
let step = 0
|
||||||
|
|
||||||
@@ -407,6 +408,10 @@ async function assemble(args) {
|
|||||||
await syncToLocalJianying(draftUrl, draftId, totalDurationUs)
|
await syncToLocalJianying(draftUrl, draftId, totalDurationUs)
|
||||||
console.log(' 同步完成\n')
|
console.log(' 同步完成\n')
|
||||||
|
|
||||||
|
// -- 合并同类型轨道(TTS 逐条降级时每条独占一个轨道)--
|
||||||
|
step++; console.log(`[${step}/${totalSteps}] 合并同类型轨道...`)
|
||||||
|
consolidateTracks(draftId)
|
||||||
|
|
||||||
// -- 视频轨道 slot 写入(在 syncToLocalJianying 之后执行,此时本地草稿文件已存在)--
|
// -- 视频轨道 slot 写入(在 syncToLocalJianying 之后执行,此时本地草稿文件已存在)--
|
||||||
if (mode !== 'images') {
|
if (mode !== 'images') {
|
||||||
step++; console.log(`[${step}/${totalSteps}] 写入视频轨道时间线...`)
|
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')
|
const { execFile } = require('child_process')
|
||||||
|
|
||||||
function triggerDirScan(dir) {
|
function triggerDirScan(dir) {
|
||||||
@@ -484,33 +552,36 @@ async function addVoiceover(draftUrl, inputDir, items, timeline, audioUrls = {})
|
|||||||
if (!item.audio) continue
|
if (!item.audio) continue
|
||||||
|
|
||||||
if (item.segments && item.segments.length > 0) {
|
if (item.segments && item.segments.length > 0) {
|
||||||
// 使用 segments 精确添加
|
// 使用 segments 精确添加,end 裁剪到 slot 边界防止跨场景重叠
|
||||||
for (const seg of item.segments) {
|
for (const seg of item.segments) {
|
||||||
if (!seg.audio || seg.error) continue
|
if (!seg.audio || seg.error) continue
|
||||||
const audioUrl = seg.audio.startsWith('http')
|
const audioUrl = seg.audio.startsWith('http')
|
||||||
? seg.audio
|
? seg.audio
|
||||||
: (audioUrls[seg.audio] || path.resolve(inputDir, 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 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({
|
segmentsFlat.push({
|
||||||
audio_url: audioUrl,
|
audio_url: audioUrl,
|
||||||
start: segStartUs,
|
start: segStartUs,
|
||||||
end: segStartUs + segDurUs,
|
end: segEndUs,
|
||||||
duration: segDurUs,
|
duration: actualDurUs,
|
||||||
volume: 1.0,
|
volume: 1.0,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 降级:整段添加
|
// 降级:整段添加,end 裁剪到 slot 边界
|
||||||
const audioUrl = item.audio.startsWith('http')
|
const audioUrl = item.audio.startsWith('http')
|
||||||
? item.audio
|
? item.audio
|
||||||
: (audioUrls[item.audio] || path.resolve(inputDir, item.audio))
|
: (audioUrls[item.audio] || path.resolve(inputDir, item.audio))
|
||||||
const audioDurUs = item.audioDuration ? item.audioDuration * US : tl.duration
|
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({
|
segmentsFlat.push({
|
||||||
audio_url: audioUrl,
|
audio_url: audioUrl,
|
||||||
start: tl.start,
|
start: tl.start,
|
||||||
end: tl.start + audioDurUs,
|
end: endUs,
|
||||||
duration: audioDurUs,
|
duration: actualDurUs,
|
||||||
volume: 1.0,
|
volume: 1.0,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -598,15 +669,15 @@ async function addSubtitles(draftUrl, items, timeline, style = {}, split = false
|
|||||||
const tl = timeline[i]
|
const tl = timeline[i]
|
||||||
|
|
||||||
if (split && item.segments && item.segments.length > 0) {
|
if (split && item.segments && item.segments.length > 0) {
|
||||||
// 精确字幕模式:使用 segments 实测时长,逐段添加字幕
|
// 精确字幕模式,end 裁剪到 slot 边界防止跨场景重叠
|
||||||
for (const seg of item.segments) {
|
for (const seg of item.segments) {
|
||||||
if (seg.error || !seg.text) continue
|
if (seg.error || !seg.text) continue
|
||||||
const segStartUs = tl.start + Math.round(seg.startOffset * US)
|
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 = {
|
const cap = {
|
||||||
start: segStartUs,
|
start: segStartUs,
|
||||||
end: segStartUs + segDurUs,
|
end: segEndUs,
|
||||||
text: seg.text,
|
text: seg.text,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -819,6 +890,7 @@ module.exports = {
|
|||||||
addKeywordOverlays,
|
addKeywordOverlays,
|
||||||
addSlots,
|
addSlots,
|
||||||
addSlotsLocally,
|
addSlotsLocally,
|
||||||
|
consolidateTracks,
|
||||||
addEffects,
|
addEffects,
|
||||||
addFilter,
|
addFilter,
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user