feat(video-pipeline): 添加可配置的转场策略引擎和TTS语速支持

- 引入转场策略系统(`getTransition`),支持 `fixed`、`director`、`rhythm` 三种模式
- 根据账号配置文件动态读取转场配置(`loadTransitions`)
- 图片和视频轨道分别调用转场策略,替代原有的固定“闪白”转场
- 支持 `byPosition`(hook/body/keypoint/closing)和 `byDirector` 两种高级选择策略
- 图片动画支持 `loop_animation` 与 `in_animation` 解析(“缩放”、“弹入”等组合)
- TTS 合成新增 `rate` 字段(源自账号配置 `ttsRate`),默认语速调整为 1.1
- 默认动画类型从 `kenburns-zoom` 改为 `缩放`,适配中文 CapCut
This commit is contained in:
2026-05-01 14:16:08 +08:00
parent 21ff5311c1
commit f5d47ec5db
7 changed files with 118 additions and 15 deletions

View File

@@ -108,6 +108,44 @@ async function uploadToOSS(filePath) {
return url return url
} }
// ============================================================================
// 转场选择策略
// ============================================================================
function getTransition(item, index, totalCount, transitionConfig) {
// 无配置 → 不加转场
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 }
switch (strategy) {
case 'director': {
// 按 directorRef 选择转场
const ref = (item.directorRef || '').toLowerCase()
const byDirector = transitionConfig.byDirector || {}
return byDirector[ref] || defaultT
}
case 'rhythm': {
// 按位置选择转场hook / body / keypoint / closing
const rules = transitionConfig.byPosition || {}
if (index === 1) return rules.hook || defaultT
if (index >= totalCount - 2) return rules.closing || defaultT
// 每隔3个 shot 用一个强调转场
if (index % 3 === 0) return rules.keypoint || defaultT
return rules.body || defaultT
}
case 'fixed':
default:
return defaultT
}
}
async function batchUploadToOSS(inputDir, files) { async function batchUploadToOSS(inputDir, files) {
const urls = {} const urls = {}
for (const file of files) { for (const file of files) {
@@ -179,7 +217,7 @@ async function assemble(args) {
format = '9:16', format = '9:16',
apiKey = '', apiKey = '',
duration = '4', duration = '4',
animation = 'kenburns-zoom', animation = '缩放',
localAudio = 'true', localAudio = 'true',
} = args } = args
@@ -284,7 +322,7 @@ async function assemble(args) {
// -- 导入素材 -- // -- 导入素材 --
step++; console.log(`[${step}/${totalSteps}] 导入素材...`) step++; console.log(`[${step}/${totalSteps}] 导入素材...`)
if (mode === 'images') { if (mode === 'images') {
await addImages(draftUrl, items, imgUrls, timeline, width, height, animation) await addImages(draftUrl, items, imgUrls, timeline, width, height, animation, transitionConfig)
} else { } else {
// 视频模式:调速 → 上传 OSS → 添加到草稿 // 视频模式:调速 → 上传 OSS → 添加到草稿
// Step 1: ffmpeg 调速(在上传前,避免传两份) // Step 1: ffmpeg 调速(在上传前,避免传两份)
@@ -330,7 +368,7 @@ async function assemble(args) {
} }
} }
} }
await addVideos(draftUrl, inputDir, items, timeline, width, height) await addVideos(draftUrl, inputDir, items, timeline, width, height, transitionConfig)
} }
// -- 添加 TTS 配音 -- // -- 添加 TTS 配音 --
@@ -355,6 +393,9 @@ async function assemble(args) {
console.log(` 字幕风格: ${subtitleStyle.font || '默认'} ${subtitleStyle.inAnimation ? subtitleStyle.inAnimation + '→' + subtitleStyle.outAnimation : ''}`) console.log(` 字幕风格: ${subtitleStyle.font || '默认'} ${subtitleStyle.inAnimation ? subtitleStyle.inAnimation + '→' + subtitleStyle.outAnimation : ''}`)
} }
// -- 读取转场策略 --
const transitionConfig = loadTransitions(manifest)
// -- 添加字幕 -- // -- 添加字幕 --
step++; console.log(`[${step}/${totalSteps}] 添加字幕...`) step++; console.log(`[${step}/${totalSteps}] 添加字幕...`)
if (subtitles === 'true' && items.some(i => i.script || i.text)) { if (subtitles === 'true' && items.some(i => i.script || i.text)) {
@@ -418,23 +459,40 @@ async function assemble(args) {
// 添加图片(自动上传到 OSS // 添加图片(自动上传到 OSS
// ============================================================================ // ============================================================================
async function addImages(draftUrl, items, imgUrls, timeline, width, height, animation = '') { async function addImages(draftUrl, items, imgUrls, timeline, width, height, animation = '', transitionConfig = null) {
const imageInfos = items.map((item, i) => { const imageInfos = items.map((item, i) => {
const url = imgUrls[item.file] const url = imgUrls[item.file]
if (!url) throw new Error(`图片 ${item.file} 未上传成功,无法添加`) if (!url) throw new Error(`图片 ${item.file} 未上传成功,无法添加`)
const tl = timeline[i] const tl = timeline[i]
const t = getTransition(item, i, items.length, transitionConfig)
return { // animation 解析:支持 "缩放" (group), "放大" (in), "渐显+缩小" (in+out)
const info = {
image_url: url, image_url: url,
width, width,
height, height,
start: tl.start, start: tl.start,
end: tl.end, end: tl.end,
duration: tl.duration, duration: tl.duration,
animation: animation || '', transition: t.name,
transition: i > 0 ? '闪白' : '', transition_duration: t.duration,
transition_duration: 150000,
} }
if (animation) {
const parts = animation.split('+')
for (const part of parts) {
const name = part.trim()
// 组合动画(持续整段):缩放、三分割 等
if (name === '缩放' || name === '缩放 II') {
info.loop_animation = name
} else {
// 默认作为入场动画
info.in_animation = name
}
}
}
return info
}) })
// 单次全量提交,所有图片在同一轨道 // 单次全量提交,所有图片在同一轨道
@@ -509,9 +567,10 @@ async function adjustVideoSpeed(videoPath, targetDurationSec) {
}) })
} }
async function addVideos(draftUrl, inputDir, items, timeline, width, height) { async function addVideos(draftUrl, inputDir, items, timeline, width, height, transitionConfig = null) {
const videoInfos = items.map((item, i) => { const videoInfos = items.map((item, i) => {
const tl = timeline[i] const tl = timeline[i]
const t = getTransition(item, i, items.length, transitionConfig)
return { return {
video_url: item.videoUrl || (item.video ? path.resolve(inputDir, item.video) : null) || item.url || path.resolve(inputDir, item.file), video_url: item.videoUrl || (item.video ? path.resolve(inputDir, item.video) : null) || item.url || path.resolve(inputDir, item.file),
width, width,
@@ -520,8 +579,8 @@ async function addVideos(draftUrl, inputDir, items, timeline, width, height) {
end: tl.end, end: tl.end,
duration: tl.duration, duration: tl.duration,
mask: '', mask: '',
transition: i > 0 ? '闪白' : '', transition: t.name,
transition_duration: 150000, transition_duration: t.duration,
volume: item.volume || 1, volume: item.volume || 1,
} }
}) })
@@ -718,6 +777,18 @@ function loadSubtitleStyle(manifest) {
} catch { return {} } } catch { return {} }
} }
function loadTransitions(manifest) {
const account = manifest.account
if (!account) return null
const scriptDir = __dirname
const accountFile = path.join(scriptDir, '..', '..', '..', 'accounts', account, 'account.json')
if (!fs.existsSync(accountFile)) return null
try {
const accountData = JSON.parse(fs.readFileSync(accountFile, 'utf-8'))
return accountData.capcut?.transitions || null
} catch { return null }
}
// ============================================================================ // ============================================================================
// 添加字幕(支持关键词高亮 + 账号字幕风格 + 分句切分) // 添加字幕(支持关键词高亮 + 账号字幕风格 + 分句切分)
// ============================================================================ // ============================================================================

View File

@@ -93,6 +93,7 @@ function initManifest(options) {
references, references,
...(accountConfig.ttsVoice ? { ttsVoice: accountConfig.ttsVoice } : {}), ...(accountConfig.ttsVoice ? { ttsVoice: accountConfig.ttsVoice } : {}),
...(accountConfig.ttsInstruction ? { ttsInstruction: accountConfig.ttsInstruction } : {}), ...(accountConfig.ttsInstruction ? { ttsInstruction: accountConfig.ttsInstruction } : {}),
...(accountConfig.ttsRate ? { ttsRate: accountConfig.ttsRate } : {}),
items, items,
} }

View File

@@ -23,7 +23,7 @@ async function phaseAssemble(manifest, manifestPath, options) {
subtitles: mode === 'images' ? 'true' : 'false', subtitles: mode === 'images' ? 'true' : 'false',
voiceover: manifest.items.some(it => it.audio) ? 'true' : 'false', voiceover: manifest.items.some(it => it.audio) ? 'true' : 'false',
duration: '4', duration: '4',
animation: capcutConfig.animation || 'kenburns-zoom', animation: capcutConfig.animation || '缩放',
} }
if (capcutConfig.defaultBGM) assembleArgs.bgm = capcutConfig.defaultBGM if (capcutConfig.defaultBGM) assembleArgs.bgm = capcutConfig.defaultBGM

View File

@@ -30,6 +30,7 @@ async function phaseTts(manifest, manifestPath, options = {}) {
id: item.id || idx, id: item.id || idx,
voice: manifest.ttsVoice || undefined, voice: manifest.ttsVoice || undefined,
instruction: manifest.ttsInstruction || undefined, instruction: manifest.ttsInstruction || undefined,
rate: manifest.ttsRate || undefined,
}) })
item.audio = path.relative(dir, filePath).replace(/\\/g, '/') item.audio = path.relative(dir, filePath).replace(/\\/g, '/')
item.audioDuration = Math.round(duration * 1000) / 1000 item.audioDuration = Math.round(duration * 1000) / 1000

View File

@@ -108,7 +108,7 @@ function synthesize(text, options = {}) {
format: 'mp3', format: 'mp3',
sample_rate: 24000, sample_rate: 24000,
volume: 50, volume: 50,
rate: 1.0, rate: options.rate || 1.1,
pitch_rate: 1.0, pitch_rate: 1.0,
text_type: 'PlainText', text_type: 'PlainText',
...(instruction ? { instruction } : {}), ...(instruction ? { instruction } : {}),

View File

@@ -21,6 +21,21 @@
"highlightColor": "#FF6B35", "highlightColor": "#FF6B35",
"bold": true "bold": true
}, },
"defaultBGM": "" "defaultBGM": "",
"transitions": {
"strategy": "rhythm",
"default": { "name": "闪白", "duration": 150000 },
"byPosition": {
"hook": { "name": "闪白", "duration": 100000 },
"keypoint": { "name": "闪白", "duration": 120000 },
"body": { "name": "溶解", "duration": 300000 },
"closing": { "name": "黑场", "duration": 200000 }
},
"byDirector": {
"tarantino": { "name": "闪白", "duration": 100000 },
"kitano": { "name": "溶解", "duration": 400000 },
"fincher": { "name": "黑场", "duration": 200000 }
}
}
} }
} }

View File

@@ -36,6 +36,21 @@
"inAnimation": "淡入", "inAnimation": "淡入",
"outAnimation": "淡出" "outAnimation": "淡出"
}, },
"defaultBGM": "" "defaultBGM": "",
"transitions": {
"strategy": "rhythm",
"default": { "name": "闪白", "duration": 150000 },
"byPosition": {
"hook": { "name": "闪白", "duration": 100000 },
"keypoint": { "name": "闪白", "duration": 120000 },
"body": { "name": "溶解", "duration": 300000 },
"closing": { "name": "黑场", "duration": 200000 }
},
"byDirector": {
"tarantino": { "name": "闪白", "duration": 100000 },
"kitano": { "name": "溶解", "duration": 400000 },
"fincher": { "name": "黑场", "duration": 200000 }
}
}
} }
} }