Files
video-create/.claude/skills/video-from-script/scripts/lib/capcut-tracks.js
sion123 6097a809bf refactor(video-pipeline): 移除 segments 机制,改为整段音频合成
移除 TTS 阶段逐句切分及 segments 数组逻辑,统一为整段音频合成。
CapCut 字幕切分由组装阶段按字符比例分配,简化音频上传、
时间线构建和字幕生成流程,减少冗余处理分支。
2026-05-02 02:31:55 +08:00

589 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* CapCut 轨道操作
*
* 所有 add* 函数 + 转场策略 + 账号配置读取。
* Agent 修改字幕风格、Ken Burns、转场、特效等只需关注此文件。
*/
const path = require('path')
const { api, US } = require('./capcut-api')
const { splitTextIntoSentences, loadAccountConfig: loadAccountConfigFromUtils } = require('./pipeline-utils')
// ============================================================================
// 账号配置读取
// ============================================================================
function loadAccountConfig(manifest) {
const account = manifest.account
if (!account) return {}
try { return loadAccountConfigFromUtils(account) } catch { return {} }
}
function loadSubtitleStyle(manifest) {
return loadAccountConfig(manifest).capcut?.subtitleStyle || {}
}
function loadKeywordStyle(manifest) {
return loadAccountConfig(manifest).capcut?.keywordStyle || {}
}
function loadTransitions(manifest) {
return loadAccountConfig(manifest).capcut?.transitions || null
}
function applyAnimationProps(cap, style = {}) {
if (style.inAnimation) cap.in_animation = style.inAnimation
if (style.outAnimation) cap.out_animation = style.outAnimation
if (style.inAnimDuration) cap.in_animation_duration = style.inAnimDuration
if (style.outAnimDuration) cap.out_animation_duration = style.outAnimDuration
}
// ============================================================================
// 转场策略
// ============================================================================
function getTransition(item, index, totalCount, transitionConfig, allItems, timeline) {
if (!transitionConfig) return { name: '', duration: 0 }
const defaultT = transitionConfig.default || { name: '闪白', duration: 150000 }
const strategy = transitionConfig.strategy || 'fixed'
if (index === 0) return { name: '', duration: 0 }
if (index >= totalCount - 1) return { name: '', duration: 0 }
switch (strategy) {
case 'director': {
const ref = (item.directorRef || '').toLowerCase()
const byDirector = transitionConfig.byDirector || {}
return byDirector[ref] || defaultT
}
case 'rhythm': {
const rules = transitionConfig.byPosition || {}
if (index >= totalCount - 2) return rules.closing || defaultT
if (allItems && index > 0) {
const prev = allItems[index - 1]
if (item.directorRef && prev.directorRef && item.directorRef !== prev.directorRef) {
return rules.keypoint || defaultT
}
if (item.keyword && !prev.keyword) {
return rules.keypoint || defaultT
}
}
if (timeline && timeline.length > 0) {
const elapsed = timeline[index].start
let lastTransStart = 0
for (let pi = index - 1; pi >= 1; pi--) {
const p = allItems[pi]
const pp = pi > 0 ? allItems[pi - 1] : null
if (pi >= totalCount - 2) { lastTransStart = timeline[pi].start; break }
if (pp && p.directorRef && pp.directorRef && p.directorRef !== pp.directorRef) {
lastTransStart = timeline[pi].start; break
}
if (p.keyword && pp && !pp.keyword) { lastTransStart = timeline[pi].start; break }
}
if (elapsed - lastTransStart >= 8000000) return rules.body || defaultT
} else {
if (index % 3 === 0) return rules.body || defaultT
}
return { name: '', duration: 0 }
}
case 'fixed':
default:
return defaultT
}
}
// ============================================================================
// 入场动画映射
// ============================================================================
const DIRECTOR_ANIMATIONS = {
tarantino: '动感放大',
kitano: '轻微放大',
fincher: '渐显',
}
function getAnimationForDirector(directorRef, defaultAnimation) {
if (!directorRef) return defaultAnimation
return DIRECTOR_ANIMATIONS[directorRef.toLowerCase()] || defaultAnimation
}
// ============================================================================
// Ken Burns 关键帧动画
// ============================================================================
const KEN_BURNS_FALLBACK = {
default: { startScale: 1.0, scaleRate: 0.8, panXRate: 0, panYRate: 0 },
byDirector: {
tarantino: { startScale: 1.0, scaleRate: 0.9, panXRate: 0, panYRate: -0.3 },
kitano: { startScale: 1.03, scaleRate: 0.5, panXRate: 0.4, panYRate: 0 },
fincher: { startScale: 1.0, scaleRate: 0.4, panXRate: 0, panYRate: 0 },
},
}
const KEN_BURNS_MAX_SCALE = 1.20
function loadKenBurns(manifest) {
const cfg = loadAccountConfig(manifest).capcut?.kenBurns
if (cfg && cfg.default) return cfg
return KEN_BURNS_FALLBACK
}
function getKenBurnsProfile(item, kbConfig) {
const director = (item.directorRef || '').toLowerCase()
const byDirector = kbConfig.byDirector || {}
return byDirector[director] || kbConfig.default
}
async function addKenBurns(draftUrl, segmentIds, items, timeline, manifest) {
if (!segmentIds || segmentIds.length === 0) {
console.log(' 无 segment IDs跳过 Ken Burns')
return
}
const kbConfig = loadKenBurns(manifest)
if (kbConfig.enabled === false) {
console.log(' Ken Burns 已禁用account.json kenBurns.enabled=false')
return
}
const keyframes = []
for (let i = 0; i < segmentIds.length; i++) {
const segId = segmentIds[i]
const item = items[i]
const tl = timeline[i]
if (!segId || !tl) continue
const profile = getKenBurnsProfile(item, kbConfig)
const durSec = tl.duration / US
const segDur = tl.duration
const startScale = profile.startScale || 1.0
const scaleRate = profile.scaleRate != null ? profile.scaleRate : 0.8
const endScale = Math.min(startScale + scaleRate * durSec / 100, KEN_BURNS_MAX_SCALE)
keyframes.push(
{ segment_id: segId, property: 'UNIFORM_SCALE', offset: 0, value: startScale },
{ segment_id: segId, property: 'UNIFORM_SCALE', offset: segDur, value: endScale },
)
const panXRate = profile.panXRate || 0
const panYRate = profile.panYRate || 0
if (panXRate !== 0) {
const panX = panXRate * durSec
keyframes.push(
{ segment_id: segId, property: 'KFTypePositionX', offset: 0, value: 0 },
{ segment_id: segId, property: 'KFTypePositionX', offset: segDur, value: panX },
)
}
if (panYRate !== 0) {
const panY = panYRate * durSec
keyframes.push(
{ segment_id: segId, property: 'KFTypePositionY', offset: 0, value: 0 },
{ segment_id: segId, property: 'KFTypePositionY', offset: segDur, value: panY },
)
}
}
if (keyframes.length === 0) {
console.log(' 无关键帧生成,跳过')
return
}
const res = await api('add_keyframes', {
draft_url: draftUrl,
keyframes: JSON.stringify(keyframes),
})
console.log(` 已添加 ${res.keyframes_added || keyframes.length} 个 Ken Burns 关键帧 (${segmentIds.length} 段)`)
}
// ============================================================================
// 添加图片
// ============================================================================
async function addImages(draftUrl, items, imgUrls, timeline, width, height, animation = '', transitionConfig = null) {
const imageInfos = items.map((item, i) => {
const url = imgUrls[item.file]
if (!url) throw new Error(`图片 ${item.file} 未上传成功,无法添加`)
const tl = timeline[i]
const t = getTransition(item, i, items.length, transitionConfig, items, timeline)
const info = {
image_url: url,
width,
height,
start: tl.start,
end: tl.end,
duration: tl.duration,
transition: t.name,
transition_duration: t.duration,
}
const itemAnimation = getAnimationForDirector(item.directorRef, animation)
if (itemAnimation) {
const parts = itemAnimation.split('+').map(p => p.trim()).filter(Boolean)
const loopNames = ['缩放', '缩放 II', '回弹伸缩', '旋转伸缩']
const loopAnims = parts.filter(p => loopNames.includes(p))
const inAnims = parts.filter(p => !loopNames.includes(p))
if (loopAnims.length > 0) info.loop_animation = loopAnims.join('|')
if (inAnims.length > 0) info.in_animation = inAnims.join('|')
}
return info
})
console.log(` 一次性添加 ${imageInfos.length} 张图片...`)
const res = await api('add_images', {
draft_url: draftUrl,
image_infos: JSON.stringify(imageInfos),
alpha: 1, scale_x: 1, scale_y: 1,
transform_x: 0, transform_y: 0,
}, 300000)
const allSegmentIds = res.segment_ids || []
console.log(` 已添加 ${items.length} 张图片`)
return allSegmentIds
}
// ============================================================================
// 添加视频
// ============================================================================
async function addVideos(draftUrl, inputDir, items, timeline, width, height, transitionConfig = null) {
const videoInfos = items.map((item, i) => {
const tl = timeline[i]
const t = getTransition(item, i, items.length, transitionConfig, items, timeline)
return {
video_url: item.videoUrl || (item.video ? path.resolve(inputDir, item.video) : null) || item.url || path.resolve(inputDir, item.file),
width,
height,
start: tl.start,
end: tl.end,
duration: tl.duration,
mask: '',
transition: t.name,
transition_duration: t.duration,
volume: item.volume || 1,
}
})
try {
const res = await api('add_videos', {
draft_url: draftUrl,
video_infos: JSON.stringify(videoInfos),
alpha: 1, scale_x: 1, scale_y: 1,
transform_x: 0, transform_y: 0,
scene_timelines: [],
})
console.log(` 已添加 ${items.length} 个视频片段(全量)`)
return res.segment_ids || []
} catch (err) {
if (!err.message.includes('504') && !err.message.includes('timeout')) throw err
console.log(` 全量提交超时,降级为分批添加...`)
}
const BATCH_SIZE = 3
const allSegmentIds = []
for (let i = 0; i < videoInfos.length; i += BATCH_SIZE) {
const batch = videoInfos.slice(i, i + BATCH_SIZE)
const batchNum = Math.floor(i / BATCH_SIZE) + 1
const totalBatches = Math.ceil(videoInfos.length / BATCH_SIZE)
console.log(` 分批 [${batchNum}/${totalBatches}] 添加 ${batch.length} 个片段...`)
const res = await api('add_videos', {
draft_url: draftUrl,
video_infos: JSON.stringify(batch),
alpha: 1, scale_x: 1, scale_y: 1,
transform_x: 0, transform_y: 0,
scene_timelines: [],
})
if (res.segment_ids) allSegmentIds.push(...res.segment_ids)
}
console.log(` 已添加 ${items.length} 个视频片段(分批)`)
return allSegmentIds
}
// ============================================================================
// 添加 TTS 配音
// ============================================================================
async function addVoiceover(draftUrl, inputDir, items, timeline, audioUrls = {}) {
const audioItems = items.filter(item => item.audio)
if (audioItems.length === 0) {
console.log(' 无 TTS 音频文件,跳过')
return
}
const audioInfos = []
const resolveAudio = (relPath) => {
if (relPath.startsWith('http')) return relPath
if (audioUrls[relPath]) return audioUrls[relPath]
return path.isAbsolute(relPath) ? relPath : path.resolve(inputDir, relPath)
}
for (let i = 0; i < items.length; i++) {
const item = items[i]
const tl = timeline[i]
if (item.audio) {
const audioUrl = resolveAudio(item.audio)
const audioDurUs = item.audioDuration ? item.audioDuration * US : tl.duration
audioInfos.push({
audio_url: audioUrl,
start: tl.start,
end: tl.start + audioDurUs,
duration: audioDurUs,
volume: 1.0,
})
}
}
if (audioInfos.length === 0) {
console.log(' 无可用音频,跳过配音')
return
}
await api('add_audios', {
draft_url: draftUrl,
audio_infos: JSON.stringify(audioInfos),
})
const ossCount = audioInfos.filter(a => a.audio_url.startsWith('http')).length
console.log(` 已添加 ${audioInfos.length} 段 TTS 配音 (${ossCount > 0 ? `${ossCount} 段 OSS + ` : ''}${audioInfos.length - ossCount} 段本地)`)
}
// ============================================================================
// 添加 BGM
// ============================================================================
async function addBGM(draftUrl, bgmUrl, totalDurationUs) {
let audioDuration = totalDurationUs
try {
const durRes = await api('get_audio_duration', { mp3_url: bgmUrl })
if (durRes.duration) audioDuration = durRes.duration
} catch (_) {}
const fadeIn = 500000
const fadeOut = 1000000
await api('add_audios', {
draft_url: draftUrl,
audio_infos: JSON.stringify([{
audio_url: bgmUrl,
duration: audioDuration,
end: Math.min(audioDuration, totalDurationUs),
start: 0,
volume: 0.15,
fade_in_duration: fadeIn,
fade_out_duration: fadeOut,
}]),
})
console.log(` 已添加 BGM (${(audioDuration / US).toFixed(1)}s, fade 0.5s/1s)`)
}
// ============================================================================
// 添加字幕
// ============================================================================
async function addSubtitles(draftUrl, items, timeline, style = {}, split = false) {
const captions = []
const animStyle = {
inAnimation: style.inAnimation || '',
outAnimation: style.outAnimation || '',
inAnimDuration: style.inAnimationDuration || null,
outAnimDuration: style.outAnimationDuration || null,
}
for (let i = 0; i < items.length; i++) {
const item = items[i]
const text = item.script || item.text || item.caption || ''
if (!text) continue
const tl = timeline[i]
if (split) {
const sentences = splitTextIntoSentences(text)
if (sentences.length === 0) continue
const totalDuration = tl.end - tl.start
const totalChars = sentences.reduce((sum, s) => sum + s.length, 0)
let currentTime = tl.start
sentences.forEach((sentence, idx) => {
const charRatio = sentence.length / totalChars
let duration = Math.round(totalDuration * charRatio)
if (idx === sentences.length - 1) {
duration = tl.end - currentTime
}
duration = Math.max(duration, 500000)
const cap = {
start: currentTime,
end: currentTime + duration,
text: sentence,
}
applyAnimationProps(cap, animStyle)
captions.push(cap)
currentTime += duration
})
} else {
const cap = {
start: tl.start,
end: tl.end,
text,
}
applyAnimationProps(cap, animStyle)
captions.push(cap)
}
}
if (captions.length === 0) {
console.log(' 无字幕内容,跳过')
return
}
await api('add_captions', {
draft_url: draftUrl,
captions: JSON.stringify(captions),
font: style.font || null,
font_size: style.fontSize || 15,
text_color: style.color || '#ffffff',
alignment: 1,
bold: style.bold || false,
italic: false,
underline: false,
has_shadow: style.hasShadow || false,
shadow_info: style.shadowAlpha ? {
shadow_alpha: style.shadowAlpha,
shadow_color: style.shadowColor || '#000000',
shadow_diffuse: 15,
shadow_distance: 5,
shadow_angle: -45,
} : undefined,
letter_spacing: style.letterSpacing || 0,
line_spacing: style.lineSpacing || 0,
alpha: style.alpha || 1,
scale_x: 1, scale_y: 1,
transform_x: 0,
transform_y: style.transformY || 0,
style_text: 0,
})
console.log(` 已添加 ${captions.length} 条字幕${split ? ' (分句模式)' : ''} (字体: ${style.font || '默认'}, 动画: ${animStyle.inAnimation || '无'}${animStyle.outAnimation || '无'})`)
}
// ============================================================================
// 添加关键字氛围词
// ============================================================================
async function addKeywordOverlays(draftUrl, items, timeline, style = {}) {
const keywordItems = items.filter(item => item.keyword)
if (keywordItems.length === 0) {
console.log(' 无关键字,跳过')
return
}
const captions = []
for (let i = 0; i < items.length; i++) {
const item = items[i]
if (!item.keyword) continue
const tl = timeline[i]
const cap = {
start: tl.start,
end: tl.end,
text: item.keyword,
}
applyAnimationProps(cap, style)
captions.push(cap)
}
if (captions.length === 0) return
await api('add_captions', {
draft_url: draftUrl,
captions: JSON.stringify(captions),
font: style.font || null,
font_size: style.fontSize || 60,
text_color: style.color || '#FFFFFF',
alignment: 1,
bold: style.bold || false,
has_shadow: style.hasShadow || false,
shadow_info: style.shadowAlpha ? {
shadow_alpha: style.shadowAlpha,
shadow_color: style.shadowColor || '#000000',
shadow_diffuse: 15,
shadow_distance: 5,
shadow_angle: -45,
} : undefined,
alpha: style.alpha || 1,
scale_x: 1, scale_y: 1,
transform_x: 0,
transform_y: style.transformY || 0,
text_effect: style.textEffect || null,
})
console.log(` 已添加 ${captions.length} 个关键字氛围词 (效果: ${style.textEffect || '无'})`)
}
// ============================================================================
// 添加特效
// ============================================================================
async function addEffects(draftUrl, effectsStr, totalDurationUs) {
const effectNames = effectsStr.split(',').map(s => s.trim()).filter(Boolean)
const effectInfos = effectNames.map(name => ({
effect_title: name,
start: 0,
end: totalDurationUs,
}))
await api('add_effects', {
draft_url: draftUrl,
effect_infos: JSON.stringify(effectInfos),
})
console.log(` 已添加: ${effectNames.join(', ')}`)
}
// ============================================================================
// 添加滤镜
// ============================================================================
async function addFilter(draftUrl, filterStr, totalDurationUs) {
const [name, intensity] = filterStr.split(':')
await api('add_filters', {
draft_url: draftUrl,
filter_infos: JSON.stringify([{
filter_title: (name || '').trim(),
start: 0,
end: totalDurationUs,
intensity: parseFloat(intensity) || 50,
}]),
})
console.log(` 已添加: ${(name || '').trim()} 强度 ${intensity || 50}`)
}
module.exports = {
loadAccountConfig,
loadSubtitleStyle,
loadKeywordStyle,
loadKenBurns,
loadTransitions,
getTransition,
addImages,
addVideos,
addKenBurns,
addVoiceover,
addBGM,
addSubtitles,
addKeywordOverlays,
addEffects,
addFilter,
}