From ce54a65abbd431c1bd96aea4102d1159dc4a325d Mon Sep 17 00:00:00 2001 From: sion123 <450702724@qq.com> Date: Thu, 7 May 2026 00:17:17 +0800 Subject: [PATCH] =?UTF-8?q?feat(capcut):=20=E5=90=88=E5=B9=B6=E5=90=8C?= =?UTF-8?q?=E7=B1=BB=E5=9E=8B=E8=BD=A8=E9=81=93=E5=B9=B6=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E9=9F=B3=E9=A2=91/=E5=AD=97=E5=B9=95=E8=BE=B9=E7=95=8C?= =?UTF-8?q?=E8=A3=81=E5=89=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在同步到本地剪映后新增轨道合并功能,将因降级添加导致的多条同类型轨道合并为单条。同时修复音频和字幕添加时可能跨场景重叠的问题,将 end 裁剪到 slot 边界。 --- .../scripts/capcut_assemble.js | 7 +- .../scripts/lib/capcut-tracks.js | 92 +++++++++++++++++-- 2 files changed, 88 insertions(+), 11 deletions(-) diff --git a/.claude/skills/video-from-script/scripts/capcut_assemble.js b/.claude/skills/video-from-script/scripts/capcut_assemble.js index 6991827..c7e254e 100644 --- a/.claude/skills/video-from-script/scripts/capcut_assemble.js +++ b/.claude/skills/video-from-script/scripts/capcut_assemble.js @@ -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}] 写入视频轨道时间线...`) diff --git a/.claude/skills/video-from-script/scripts/lib/capcut-tracks.js b/.claude/skills/video-from-script/scripts/lib/capcut-tracks.js index d42bf1e..4fef324 100644 --- a/.claude/skills/video-from-script/scripts/lib/capcut-tracks.js +++ b/.claude/skills/video-from-script/scripts/lib/capcut-tracks.js @@ -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, }