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:
@@ -108,6 +108,44 @@ async function uploadToOSS(filePath) {
|
||||
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) {
|
||||
const urls = {}
|
||||
for (const file of files) {
|
||||
@@ -179,7 +217,7 @@ async function assemble(args) {
|
||||
format = '9:16',
|
||||
apiKey = '',
|
||||
duration = '4',
|
||||
animation = 'kenburns-zoom',
|
||||
animation = '缩放',
|
||||
localAudio = 'true',
|
||||
} = args
|
||||
|
||||
@@ -284,7 +322,7 @@ async function assemble(args) {
|
||||
// -- 导入素材 --
|
||||
step++; console.log(`[${step}/${totalSteps}] 导入素材...`)
|
||||
if (mode === 'images') {
|
||||
await addImages(draftUrl, items, imgUrls, timeline, width, height, animation)
|
||||
await addImages(draftUrl, items, imgUrls, timeline, width, height, animation, transitionConfig)
|
||||
} else {
|
||||
// 视频模式:调速 → 上传 OSS → 添加到草稿
|
||||
// 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 配音 --
|
||||
@@ -355,6 +393,9 @@ async function assemble(args) {
|
||||
console.log(` 字幕风格: ${subtitleStyle.font || '默认'} ${subtitleStyle.inAnimation ? subtitleStyle.inAnimation + '→' + subtitleStyle.outAnimation : ''}`)
|
||||
}
|
||||
|
||||
// -- 读取转场策略 --
|
||||
const transitionConfig = loadTransitions(manifest)
|
||||
|
||||
// -- 添加字幕 --
|
||||
step++; console.log(`[${step}/${totalSteps}] 添加字幕...`)
|
||||
if (subtitles === 'true' && items.some(i => i.script || i.text)) {
|
||||
@@ -418,23 +459,40 @@ async function assemble(args) {
|
||||
// 添加图片(自动上传到 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 url = imgUrls[item.file]
|
||||
if (!url) throw new Error(`图片 ${item.file} 未上传成功,无法添加`)
|
||||
const tl = timeline[i]
|
||||
const t = getTransition(item, i, items.length, transitionConfig)
|
||||
|
||||
return {
|
||||
// animation 解析:支持 "缩放" (group), "放大" (in), "渐显+缩小" (in+out)
|
||||
const info = {
|
||||
image_url: url,
|
||||
width,
|
||||
height,
|
||||
start: tl.start,
|
||||
end: tl.end,
|
||||
duration: tl.duration,
|
||||
animation: animation || '',
|
||||
transition: i > 0 ? '闪白' : '',
|
||||
transition_duration: 150000,
|
||||
transition: t.name,
|
||||
transition_duration: t.duration,
|
||||
}
|
||||
|
||||
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 tl = timeline[i]
|
||||
const t = getTransition(item, i, items.length, transitionConfig)
|
||||
return {
|
||||
video_url: item.videoUrl || (item.video ? path.resolve(inputDir, item.video) : null) || item.url || path.resolve(inputDir, item.file),
|
||||
width,
|
||||
@@ -520,8 +579,8 @@ async function addVideos(draftUrl, inputDir, items, timeline, width, height) {
|
||||
end: tl.end,
|
||||
duration: tl.duration,
|
||||
mask: '',
|
||||
transition: i > 0 ? '闪白' : '',
|
||||
transition_duration: 150000,
|
||||
transition: t.name,
|
||||
transition_duration: t.duration,
|
||||
volume: item.volume || 1,
|
||||
}
|
||||
})
|
||||
@@ -718,6 +777,18 @@ function loadSubtitleStyle(manifest) {
|
||||
} 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 }
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 添加字幕(支持关键词高亮 + 账号字幕风格 + 分句切分)
|
||||
// ============================================================================
|
||||
|
||||
@@ -93,6 +93,7 @@ function initManifest(options) {
|
||||
references,
|
||||
...(accountConfig.ttsVoice ? { ttsVoice: accountConfig.ttsVoice } : {}),
|
||||
...(accountConfig.ttsInstruction ? { ttsInstruction: accountConfig.ttsInstruction } : {}),
|
||||
...(accountConfig.ttsRate ? { ttsRate: accountConfig.ttsRate } : {}),
|
||||
items,
|
||||
}
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ async function phaseAssemble(manifest, manifestPath, options) {
|
||||
subtitles: mode === 'images' ? 'true' : 'false',
|
||||
voiceover: manifest.items.some(it => it.audio) ? 'true' : 'false',
|
||||
duration: '4',
|
||||
animation: capcutConfig.animation || 'kenburns-zoom',
|
||||
animation: capcutConfig.animation || '缩放',
|
||||
}
|
||||
|
||||
if (capcutConfig.defaultBGM) assembleArgs.bgm = capcutConfig.defaultBGM
|
||||
|
||||
@@ -30,6 +30,7 @@ async function phaseTts(manifest, manifestPath, options = {}) {
|
||||
id: item.id || idx,
|
||||
voice: manifest.ttsVoice || undefined,
|
||||
instruction: manifest.ttsInstruction || undefined,
|
||||
rate: manifest.ttsRate || undefined,
|
||||
})
|
||||
item.audio = path.relative(dir, filePath).replace(/\\/g, '/')
|
||||
item.audioDuration = Math.round(duration * 1000) / 1000
|
||||
|
||||
@@ -108,7 +108,7 @@ function synthesize(text, options = {}) {
|
||||
format: 'mp3',
|
||||
sample_rate: 24000,
|
||||
volume: 50,
|
||||
rate: 1.0,
|
||||
rate: options.rate || 1.1,
|
||||
pitch_rate: 1.0,
|
||||
text_type: 'PlainText',
|
||||
...(instruction ? { instruction } : {}),
|
||||
|
||||
@@ -21,6 +21,21 @@
|
||||
"highlightColor": "#FF6B35",
|
||||
"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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,21 @@
|
||||
"inAnimation": "淡入",
|
||||
"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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user