@@ -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 ,
anima tion: animation || '' ,
transition : i > 0 ? '闪白' : '' ,
transition _duration : 150000 ,
transi tion: 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 }
}
// ============================================================================
// 添加字幕(支持关键词高亮 + 账号字幕风格 + 分句切分)
// ============================================================================