feat(video-pipeline): 添加 Ken Burns 镜头动画及账号创建流程优化
- 新增 Ken Burns 关键帧动画功能,为图片片段添加慢速缩放/平移效果 - 重构账号创建流程为10维度画像体系(定位→受众→内容气质→视觉基调→画风质感→色彩体系→镜头运动→声音身份→文字呈现→节奏律动) - 新增爆款潜力自检机制,在汇总确认前检查差异化定位、情绪价值等指标 - 优化转场策略,基于累计时长间隔添加转场动画 - 改进 OSS 上传功能,支持并发上传 - 添加 BGM 淡入淡出效果(0.5s淡入/1s淡出) - 新增 Hook 策略偏好(结论前置/认知冲突/身份挑衅等)和目标情绪回路字段 - 更新模板 JSON 配置,集成 kenBurns 字段
This commit is contained in:
@@ -113,7 +113,7 @@ async function uploadToOSS(filePath) {
|
||||
// 转场选择策略
|
||||
// ============================================================================
|
||||
|
||||
function getTransition(item, index, totalCount, transitionConfig, allItems) {
|
||||
function getTransition(item, index, totalCount, transitionConfig, allItems, timeline) {
|
||||
// 无配置 → 不加转场
|
||||
if (!transitionConfig) return { name: '', duration: 0 }
|
||||
|
||||
@@ -135,7 +135,7 @@ function getTransition(item, index, totalCount, transitionConfig, allItems) {
|
||||
}
|
||||
|
||||
case 'rhythm': {
|
||||
// 按位置选择转场:hook/keypoint/closing 加转场,body 段隔 3 段加一次
|
||||
// 按位置选择转场:hook/keypoint/closing 加转场,body 段按累计时长间隔
|
||||
const rules = transitionConfig.byPosition || {}
|
||||
// 收尾段
|
||||
if (index >= totalCount - 2) return rules.closing || defaultT
|
||||
@@ -149,8 +149,25 @@ function getTransition(item, index, totalCount, transitionConfig, allItems) {
|
||||
return rules.keypoint || defaultT
|
||||
}
|
||||
}
|
||||
// 固定间隔兜底(每 3 段)
|
||||
if (index % 3 === 0) return rules.body || defaultT
|
||||
// 基于累计时长的间隔(约每 8 秒加一次转场)
|
||||
if (timeline && timeline.length > 0) {
|
||||
const elapsed = timeline[index].start
|
||||
// 向前搜索上一个有转场的段落(只看 directorRef/keyword/closing,不递归)
|
||||
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 {
|
||||
// 无 timeline 时回退到固定间隔
|
||||
if (index % 3 === 0) return rules.body || defaultT
|
||||
}
|
||||
return { name: '', duration: 0 }
|
||||
}
|
||||
|
||||
@@ -160,18 +177,24 @@ function getTransition(item, index, totalCount, transitionConfig, allItems) {
|
||||
}
|
||||
}
|
||||
|
||||
async function batchUploadToOSS(inputDir, files) {
|
||||
async function batchUploadToOSS(inputDir, files, concurrency = 3) {
|
||||
const urls = {}
|
||||
for (const file of files) {
|
||||
const filePath = path.join(inputDir, file)
|
||||
if (!fs.existsSync(filePath)) continue
|
||||
try {
|
||||
urls[file] = await uploadToOSS(filePath)
|
||||
console.log(` 上传: ${file} -> OK`)
|
||||
} catch (err) {
|
||||
console.error(` 上传失败: ${file} - ${err.message}`)
|
||||
const queue = [...files]
|
||||
const workers = Array(Math.min(concurrency, queue.length)).fill(null).map(async () => {
|
||||
while (queue.length > 0) {
|
||||
const file = queue.shift()
|
||||
if (!file) break
|
||||
const filePath = path.join(inputDir, file)
|
||||
if (!fs.existsSync(filePath)) continue
|
||||
try {
|
||||
urls[file] = await uploadToOSS(filePath)
|
||||
console.log(` 上传: ${file} -> OK`)
|
||||
} catch (err) {
|
||||
console.error(` 上传失败: ${file} - ${err.message}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
await Promise.all(workers)
|
||||
return urls
|
||||
}
|
||||
|
||||
@@ -231,7 +254,7 @@ async function assemble(args) {
|
||||
format = '9:16',
|
||||
apiKey = '',
|
||||
duration = '4',
|
||||
animation = '缩放',
|
||||
animation = '轻微放大',
|
||||
} = args
|
||||
|
||||
if (!input) throw new Error('缺少 --input 参数')
|
||||
@@ -317,7 +340,7 @@ async function assemble(args) {
|
||||
|
||||
const steps = []
|
||||
if (mode === 'images') steps.push('upload')
|
||||
steps.push('draft', 'materials', 'audio_oss', 'voiceover', 'audio', 'subtitles', 'keywords', 'effects', 'filter', 'save', 'sync')
|
||||
steps.push('draft', 'materials', 'kenburns', 'audio_oss', 'voiceover', 'audio', 'subtitles', 'keywords', 'effects', 'filter', 'save', 'sync')
|
||||
const totalSteps = steps.length
|
||||
let step = 0
|
||||
|
||||
@@ -353,8 +376,9 @@ async function assemble(args) {
|
||||
|
||||
// -- 导入素材 --
|
||||
step++; console.log(`[${step}/${totalSteps}] 导入素材...`)
|
||||
let imageSegmentIds = []
|
||||
if (mode === 'images') {
|
||||
await addImages(draftUrl, items, imgUrls, timeline, width, height, animation, transitionConfig)
|
||||
imageSegmentIds = await addImages(draftUrl, items, imgUrls, timeline, width, height, animation, transitionConfig)
|
||||
} else {
|
||||
// 视频模式:调速 → 上传 OSS → 添加到草稿
|
||||
// Step 1: ffmpeg 调速(在上传前,避免传两份)
|
||||
@@ -402,6 +426,12 @@ async function assemble(args) {
|
||||
await addVideos(draftUrl, inputDir, items, timeline, width, height, transitionConfig)
|
||||
}
|
||||
|
||||
// -- Ken Burns 关键帧动画(消除幻灯片感) --
|
||||
if (mode === 'images' && imageSegmentIds.length > 0) {
|
||||
step++; console.log(`[${step}/${totalSteps}] 添加 Ken Burns 镜头动画...`)
|
||||
await addKenBurns(draftUrl, imageSegmentIds, items, timeline, manifest)
|
||||
}
|
||||
|
||||
// -- 上传 TTS 音频到 OSS --
|
||||
let audioUrls = {}
|
||||
if (voiceover === 'true' && hasTTS) {
|
||||
@@ -527,11 +557,11 @@ async function assemble(args) {
|
||||
// 添加图片(自动上传到 OSS)
|
||||
// ============================================================================
|
||||
|
||||
// 按 directorRef 映射不同动画风格
|
||||
// 按 directorRef 映射不同入场动画(IntroType,一次性不循环,不晕)
|
||||
const DIRECTOR_ANIMATIONS = {
|
||||
tarantino: '缩放', // 快速冲击感缩放
|
||||
kitano: '回弹伸缩', // 缓慢静谧的弹性平移
|
||||
fincher: '缩放 II', // 精确微距推进
|
||||
tarantino: '动感放大', // 强冲击感
|
||||
kitano: '轻微放大', // 静谧微推进
|
||||
fincher: '渐显', // 克制、精确、不露痕迹
|
||||
}
|
||||
|
||||
function getAnimationForDirector(directorRef, defaultAnimation) {
|
||||
@@ -544,9 +574,7 @@ async function addImages(draftUrl, items, imgUrls, timeline, width, height, anim
|
||||
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)
|
||||
|
||||
// animation 解析:支持 "缩放" (group), "放大" (in), "渐显+缩小" (in+out)
|
||||
const t = getTransition(item, i, items.length, transitionConfig, items, timeline)
|
||||
const info = {
|
||||
image_url: url,
|
||||
width,
|
||||
@@ -589,6 +617,97 @@ async function addImages(draftUrl, items, imgUrls, timeline, width, height, anim
|
||||
return allSegmentIds
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Ken Burns 关键帧动画(慢速缩放/平移,消除幻灯片感)
|
||||
// ============================================================================
|
||||
|
||||
// 向后兼容:account.json 未配置 kenBurns 时的默认值
|
||||
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 // 微秒
|
||||
|
||||
// 速率驱动:scaleRate(%/秒)× 时长 = 实际 endScale,clamp 上限
|
||||
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 },
|
||||
)
|
||||
|
||||
// 平移速率驱动:panXRate/panYRate(单位/秒)× 时长 = 实际偏移
|
||||
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} 段)`)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 添加视频(从 manifest 读取时长)
|
||||
// ============================================================================
|
||||
@@ -650,7 +769,7 @@ async function adjustVideoSpeed(videoPath, targetDurationSec) {
|
||||
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)
|
||||
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,
|
||||
@@ -791,14 +910,15 @@ async function addVoiceover(draftUrl, inputDir, items, timeline, audioUrls = {})
|
||||
})
|
||||
}
|
||||
} else if (item.audio) {
|
||||
// 单段音频
|
||||
// 单段音频:用实际音频时长,不超过 timeline 时长
|
||||
const audioUrl = resolveAudio(item.audio)
|
||||
const audioDurUs = item.audioDuration ? item.audioDuration * US : tl.duration
|
||||
|
||||
audioInfos.push({
|
||||
audio_url: audioUrl,
|
||||
start: tl.start,
|
||||
end: tl.end,
|
||||
duration: tl.duration,
|
||||
end: tl.start + audioDurUs,
|
||||
duration: audioDurUs,
|
||||
volume: 1.0,
|
||||
})
|
||||
}
|
||||
@@ -831,6 +951,9 @@ async function addBGM(draftUrl, bgmUrl, totalDurationUs) {
|
||||
// 无法获取时长就用视频总时长
|
||||
}
|
||||
|
||||
const fadeIn = 500000 // 0.5s 淡入
|
||||
const fadeOut = 1000000 // 1s 淡出
|
||||
|
||||
await api('add_audios', {
|
||||
draft_url: draftUrl,
|
||||
audio_infos: JSON.stringify([{
|
||||
@@ -839,9 +962,11 @@ async function addBGM(draftUrl, bgmUrl, totalDurationUs) {
|
||||
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)`)
|
||||
console.log(` 已添加 BGM (${(audioDuration / US).toFixed(1)}s, fade 0.5s/1s)`)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -997,7 +1122,7 @@ async function addSubtitles(draftUrl, items, timeline, style = {}, split = false
|
||||
duration = tl.end - currentTime
|
||||
}
|
||||
|
||||
duration = Math.max(duration, 1000000)
|
||||
duration = Math.max(duration, 500000)
|
||||
|
||||
const cap = {
|
||||
start: currentTime,
|
||||
|
||||
Reference in New Issue
Block a user