feat(video-pipeline): 重构转场与字幕系统,提取公用辅助函数
- 优化转场策略:仅首尾段跳过转场,rhythm模式下支持directorRef和keyword驱动的动态转场 - 提取 `applyAnimationProps` 和 `distributeSegments` 公用辅助函数,减少重复代码 - 支持按导演风格 (`tarantino`/`kitano`/`fincher`) 自动选择入场动画 - 重构字幕与配音段的时长分配逻辑,提高精确性 - 修复多处 `writeFileSync` 为 `saveManifest` 以统一清单写入入口
This commit is contained in:
@@ -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 || '无'})`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
Reference in New Issue
Block a user