feat(video-pipeline): 重构转场与字幕系统,提取公用辅助函数

- 优化转场策略:仅首尾段跳过转场,rhythm模式下支持directorRef和keyword驱动的动态转场
- 提取 `applyAnimationProps` 和 `distributeSegments` 公用辅助函数,减少重复代码
- 支持按导演风格 (`tarantino`/`kitano`/`fincher`) 自动选择入场动画
- 重构字幕与配音段的时长分配逻辑,提高精确性
- 修复多处 `writeFileSync` 为 `saveManifest` 以统一清单写入入口
This commit is contained in:
2026-05-01 16:12:03 +08:00
parent 350f05fc3f
commit 74680c5ea6

View File

@@ -17,7 +17,7 @@ const path = require('path')
const fs = require('fs') const fs = require('fs')
const { execFile } = require('child_process') const { execFile } = require('child_process')
const { syncDraft, registerDraft, triggerDirectoryScan } = require('./sync-to-jianying') const { syncDraft, registerDraft, triggerDirectoryScan } = require('./sync-to-jianying')
const { splitTextIntoSentences } = require('./lib/pipeline-utils') const { splitTextIntoSentences, loadAccountConfig: loadAccountConfigFromUtils, saveManifest } = require('./lib/pipeline-utils')
// ============================================================================ // ============================================================================
// 配置 // 配置
@@ -113,15 +113,18 @@ async function uploadToOSS(filePath) {
// 转场选择策略 // 转场选择策略
// ============================================================================ // ============================================================================
function getTransition(item, index, totalCount, transitionConfig) { function getTransition(item, index, totalCount, transitionConfig, allItems) {
// 无配置 → 不加转场 // 无配置 → 不加转场
if (!transitionConfig) return { name: '', duration: 0 } if (!transitionConfig) return { name: '', duration: 0 }
const defaultT = transitionConfig.default || { name: '闪白', duration: 150000 } const defaultT = transitionConfig.default || { name: '闪白', duration: 150000 }
const strategy = transitionConfig.strategy || 'fixed' const strategy = transitionConfig.strategy || 'fixed'
// 前两段不加转场(避免开头黑屏/闪烁) // 第一段不加转场(避免开头黑屏/闪烁)
if (index <= 1) return { name: '', duration: 0 } if (index === 0) return { name: '', duration: 0 }
// 最后一段不需要转场(没有下一段可过渡)
if (index >= totalCount - 1) return { name: '', duration: 0 }
switch (strategy) { switch (strategy) {
case 'director': { case 'director': {
@@ -132,10 +135,22 @@ function getTransition(item, index, totalCount, transitionConfig) {
} }
case 'rhythm': { case 'rhythm': {
// 按位置选择转场:只在 keypoint / closing 加转场,其余不加 // 按位置选择转场:hook/keypoint/closing 加转场,body 段隔 3 段加一次
const rules = transitionConfig.byPosition || {} const rules = transitionConfig.byPosition || {}
// 收尾段
if (index >= totalCount - 2) return rules.closing || defaultT if (index >= totalCount - 2) return rules.closing || defaultT
if (index % 4 === 0) return rules.keypoint || defaultT // 内容结构变化检测directorRef 变化处、keyword 标记处加转场
if (allItems && index > 0) {
const prev = allItems[index - 1]
if (item.directorRef && prev.directorRef && item.directorRef !== prev.directorRef) {
return rules.keypoint || defaultT
}
if (item.keyword && !prev.keyword) {
return rules.keypoint || defaultT
}
}
// 固定间隔兜底(每 3 段)
if (index % 3 === 0) return rules.body || defaultT
return { name: '', duration: 0 } return { name: '', duration: 0 }
} }
@@ -375,8 +390,8 @@ async function assemble(args) {
if (manifestFile) { if (manifestFile) {
try { try {
const m = JSON.parse(fs.readFileSync(manifestFile, 'utf-8')) const m = JSON.parse(fs.readFileSync(manifestFile, 'utf-8'))
const mi = m.items.find(i => i.id === item.id || i.script === item.script || i.text === item.text) const mi = m.items.find(i => i.id === item.id)
if (mi) { mi.videoUrl = url; fs.writeFileSync(manifestFile, JSON.stringify(m, null, 2)) } if (mi) { mi.videoUrl = url; saveManifest(manifestFile, m) }
} catch (_) {} } catch (_) {}
} }
} catch (err) { } catch (err) {
@@ -411,7 +426,7 @@ async function assemble(args) {
} }
} }
} }
if (changed) fs.writeFileSync(manifestFile, JSON.stringify(manifest, null, 2)) if (changed) saveManifest(manifestFile, manifest)
} }
} catch (err) { } catch (err) {
console.log(` OSS 上传失败,将尝试本地路径: ${err.message}\n`) console.log(` OSS 上传失败,将尝试本地路径: ${err.message}\n`)
@@ -512,12 +527,24 @@ async function assemble(args) {
// 添加图片(自动上传到 OSS // 添加图片(自动上传到 OSS
// ============================================================================ // ============================================================================
// 按 directorRef 映射不同动画风格
const DIRECTOR_ANIMATIONS = {
tarantino: '缩放', // 快速冲击感缩放
kitano: '回弹伸缩', // 缓慢静谧的弹性平移
fincher: '缩放 II', // 精确微距推进
}
function getAnimationForDirector(directorRef, defaultAnimation) {
if (!directorRef) return defaultAnimation
return DIRECTOR_ANIMATIONS[directorRef.toLowerCase()] || defaultAnimation
}
async function addImages(draftUrl, items, imgUrls, timeline, width, height, animation = '', transitionConfig = null) { 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) const t = getTransition(item, i, items.length, transitionConfig, items)
// animation 解析:支持 "缩放" (group), "放大" (in), "渐显+缩小" (in+out) // animation 解析:支持 "缩放" (group), "放大" (in), "渐显+缩小" (in+out)
const info = { const info = {
@@ -531,12 +558,13 @@ async function addImages(draftUrl, items, imgUrls, timeline, width, height, anim
transition_duration: t.duration, transition_duration: t.duration,
} }
// animation 解析:循环动画 → loop_animation其余 → in_animation // animation 解析:按 directorRef 选择风格,循环动画 → loop_animation
// GroupAnimationType循环缩放, 缩放 II, 回弹伸缩, 旋转伸缩, ... // GroupAnimationType循环缩放, 缩放 II, 回弹伸缩, 旋转伸缩, ...
// IntroType入场渐显, 放大, 缩小, 向右滑动, 轻微放大, ... // IntroType入场渐显, 放大, 缩小, 向右滑动, 轻微放大, ...
// OutroType出场渐隐, 缩小, 放大, 向左滑动, ... // OutroType出场渐隐, 缩小, 放大, 向左滑动, ...
if (animation) { const itemAnimation = getAnimationForDirector(item.directorRef, animation)
const parts = animation.split('+').map(p => p.trim()).filter(Boolean) if (itemAnimation) {
const parts = itemAnimation.split('+').map(p => p.trim()).filter(Boolean)
const loopNames = ['缩放', '缩放 II', '回弹伸缩', '旋转伸缩'] const loopNames = ['缩放', '缩放 II', '回弹伸缩', '旋转伸缩']
const loopAnims = parts.filter(p => loopNames.includes(p)) const loopAnims = parts.filter(p => loopNames.includes(p))
const inAnims = parts.filter(p => !loopNames.includes(p)) const inAnims = parts.filter(p => !loopNames.includes(p))
@@ -594,8 +622,8 @@ async function adjustVideoSpeed(videoPath, targetDurationSec) {
const atempo = Math.min(speed, 2.0) // atempo 单次上限 2.0 const atempo = Math.min(speed, 2.0) // atempo 单次上限 2.0
execFile('ffmpeg', [ execFile('ffmpeg', [
'-y', '-i', videoPath, '-y', '-i', videoPath,
'-filter_complex', `setpts=PTS/${speed}${videoPath.endsWith('.mp4') ? `,atempo=${atempo}` : ''}`, '-filter_complex', `setpts=PTS/${speed}`,
'-an', // 去掉音频(视频模式不需要原始音轨) '-an',
outPath outPath
], { timeout: 30000 }, (err) => { ], { timeout: 30000 }, (err) => {
if (err) { console.log(` 调速失败,使用原始视频: ${err.message}`); resolve(videoPath); return } if (err) { console.log(` 调速失败,使用原始视频: ${err.message}`); resolve(videoPath); return }
@@ -750,27 +778,17 @@ async function addVoiceover(draftUrl, inputDir, items, timeline, audioUrls = {})
if (segments) { if (segments) {
// 多段音频:按 segment 逐段添加,使用精确时长 // 多段音频:按 segment 逐段添加,使用精确时长
const totalSegDur = segments.reduce((sum, s) => sum + s.duration * US, 0) const slots = distributeSegments(tl, segments)
const tlDuration = tl.end - tl.start
let currentTime = tl.start
for (let j = 0; j < segments.length; j++) {
const seg = segments[j]
const segDurUs = Math.round(seg.duration * US)
let duration = Math.round(tlDuration * (segDurUs / totalSegDur))
if (j === segments.length - 1) duration = tl.end - currentTime
duration = Math.max(duration, 100000)
const audioUrl = resolveAudio(seg.audio)
for (const slot of slots) {
const audioUrl = resolveAudio(slot.audio)
audioInfos.push({ audioInfos.push({
audio_url: audioUrl, audio_url: audioUrl,
start: currentTime, start: slot.start,
end: currentTime + duration, end: slot.end,
duration, duration: slot.duration,
volume: 1.0, volume: 1.0,
}) })
currentTime += duration
} }
} else if (item.audio) { } else if (item.audio) {
// 单段音频 // 单段音频
@@ -830,12 +848,35 @@ async function addBGM(draftUrl, bgmUrl, totalDurationUs) {
// 读取账号配置 // 读取账号配置
// ============================================================================ // ============================================================================
// 动画属性注入DRY helper
function applyAnimationProps(cap, style = {}) {
if (style.inAnimation) cap.in_animation = style.inAnimation
if (style.outAnimation) cap.out_animation = style.outAnimation
if (style.inAnimDuration) cap.in_animation_duration = style.inAnimDuration
if (style.outAnimDuration) cap.out_animation_duration = style.outAnimDuration
}
// segments 按比例分配到时间线DRY helper
function distributeSegments(tl, segments) {
const totalSegDur = segments.reduce((sum, s) => sum + (s.duration || 0) * US, 0)
if (totalSegDur <= 0) return []
const tlDuration = tl.end - tl.start
let currentTime = tl.start
return segments.map((seg, idx) => {
const segDurUs = Math.round((seg.duration || 0) * US)
let duration = Math.round(tlDuration * (segDurUs / totalSegDur))
if (idx === segments.length - 1) duration = tl.end - currentTime
duration = Math.max(duration, 100000)
const entry = { start: currentTime, end: currentTime + duration, duration, text: seg.text, audio: seg.audio }
currentTime += duration
return entry
})
}
function loadAccountConfig(manifest) { function loadAccountConfig(manifest) {
const account = manifest.account const account = manifest.account
if (!account) return {} if (!account) return {}
const accountFile = path.join(__dirname, '..', '..', '..', '..', 'accounts', account, 'account.json') try { return loadAccountConfigFromUtils(account) } catch { return {} }
if (!fs.existsSync(accountFile)) return {}
try { return JSON.parse(fs.readFileSync(accountFile, 'utf-8')) } catch { return {} }
} }
function loadSubtitleStyle(manifest) { function loadSubtitleStyle(manifest) {
@@ -868,10 +909,7 @@ async function addKeywordOverlays(draftUrl, items, timeline, style = {}) {
end: tl.end, end: tl.end,
text: item.keyword, text: item.keyword,
} }
if (style.inAnimation) cap.in_animation = style.inAnimation applyAnimationProps(cap, style)
if (style.outAnimation) cap.out_animation = style.outAnimation
if (style.inAnimDuration) cap.in_animation_duration = style.inAnimDuration
if (style.outAnimDuration) cap.out_animation_duration = style.outAnimDuration
captions.push(cap) captions.push(cap)
} }
@@ -915,10 +953,12 @@ async function addSubtitles(draftUrl, items, timeline, style = {}, split = false
const captions = [] const captions = []
// 从账号配置读取动画参数 // 从账号配置读取动画参数
const inAnimation = style.inAnimation || '' const animStyle = {
const outAnimation = style.outAnimation || '' inAnimation: style.inAnimation || '',
const inAnimDuration = style.inAnimationDuration || null outAnimation: style.outAnimation || '',
const outAnimDuration = style.outAnimationDuration || null inAnimDuration: style.inAnimationDuration || null,
outAnimDuration: style.outAnimationDuration || null,
}
for (let i = 0; i < items.length; i++) { for (let i = 0; i < items.length; i++) {
const item = items[i] const item = items[i]
@@ -933,33 +973,13 @@ async function addSubtitles(draftUrl, items, timeline, style = {}, split = false
if (segments) { if (segments) {
// 精确模式:用 segments 的实际音频时长 // 精确模式:用 segments 的实际音频时长
const totalSegDur = segments.reduce((sum, s) => sum + s.duration * US, 0) const slots = distributeSegments(tl, segments)
const tlDuration = tl.end - tl.start
let currentTime = tl.start
segments.forEach((seg, idx) => {
const segDurUs = Math.round(seg.duration * US)
// 按实际时长占比映射到时间线(处理 ffprobe 重新测量的差异)
let duration = Math.round(tlDuration * (segDurUs / totalSegDur))
if (idx === segments.length - 1) {
duration = tl.end - currentTime
}
duration = Math.max(duration, 1000000)
const cap = {
start: currentTime,
end: currentTime + duration,
text: seg.text,
}
if (inAnimation) cap.in_animation = inAnimation
if (outAnimation) cap.out_animation = outAnimation
if (inAnimDuration) cap.in_animation_duration = inAnimDuration
if (outAnimDuration) cap.out_animation_duration = outAnimDuration
for (const slot of slots) {
const cap = { start: slot.start, end: slot.end, text: slot.text }
applyAnimationProps(cap, animStyle)
captions.push(cap) captions.push(cap)
currentTime += duration }
})
} else { } else {
// 回退:字数权重估算 // 回退:字数权重估算
const sentences = splitTextIntoSentences(text) const sentences = splitTextIntoSentences(text)
@@ -985,11 +1005,7 @@ async function addSubtitles(draftUrl, items, timeline, style = {}, split = false
text: sentence, text: sentence,
} }
if (inAnimation) cap.in_animation = inAnimation applyAnimationProps(cap, animStyle)
if (outAnimation) cap.out_animation = outAnimation
if (inAnimDuration) cap.in_animation_duration = inAnimDuration
if (outAnimDuration) cap.out_animation_duration = outAnimDuration
captions.push(cap) captions.push(cap)
currentTime += duration currentTime += duration
}) })
@@ -1002,11 +1018,7 @@ async function addSubtitles(draftUrl, items, timeline, style = {}, split = false
text, text,
} }
if (inAnimation) cap.in_animation = inAnimation applyAnimationProps(cap, animStyle)
if (outAnimation) cap.out_animation = outAnimation
if (inAnimDuration) cap.in_animation_duration = inAnimDuration
if (outAnimDuration) cap.out_animation_duration = outAnimDuration
captions.push(cap) captions.push(cap)
} }
} }
@@ -1043,7 +1055,7 @@ async function addSubtitles(draftUrl, items, timeline, style = {}, split = false
style_text: 0, style_text: 0,
}) })
console.log(` 已添加 ${captions.length} 条字幕${split ? ' (分句模式)' : ''} (字体: ${style.font || '默认'}, 动画: ${inAnimation || '无'}${outAnimation || '无'})`) console.log(` 已添加 ${captions.length} 条字幕${split ? ' (分句模式)' : ''} (字体: ${style.font || '默认'}, 动画: ${animStyle.inAnimation || '无'}${animStyle.outAnimation || '无'})`)
} }
// ============================================================================ // ============================================================================