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 }
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 添加字幕(支持关键词高亮 + 账号字幕风格 + 分句切分)
|
||||
// ============================================================================
|
||||
|
||||
Reference in New Issue
Block a user