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