Files
video-create/.claude/skills/video-from-script/scripts/lib/capcut-tracks.js
sion123 b309f54430 feat(capcut): 优化音频/字幕添加策略并重构语音切分逻辑
- 音频和字幕 API 调用改为先批量添加,批量失败时逐个兜底
- 重写 `splitIntoAudioSegments`,基于原始标点保留切分,合并短片段
- `qwen-tts.js` 补充中文逗号作为句末标点判断
2026-05-06 23:21:40 +08:00

824 lines
26 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* CapCut 轨道操作
*
* 所有 add* 函数 + 转场策略 + 账号配置读取。
* Agent 修改字幕风格、Ken Burns、转场、特效等只需关注此文件。
*
* 音频策略(固化铁律):
* - 音频由 TTS 1.15x 生成,导入 CapCut 时无 speed 字段(不可调速)
* - 每个 item 的 segments[] 逐段添加,各段 start 按 startOffset 精确对齐
*/
const path = require('path')
const fs = require('fs')
const { api, US, getConfig } = require('./capcut-api')
const { splitTextIntoSentences, loadAccountConfig: loadAccountConfigFromUtils, getManifestDir } = require('./pipeline-utils')
// ============================================================================
// 账号配置读取
// ============================================================================
function loadAccountConfig(manifest) {
const account = manifest.account
if (!account) return {}
try { return loadAccountConfigFromUtils(account) } catch { return {} }
}
function loadSubtitleStyle(manifest) {
return loadAccountConfig(manifest).capcut?.subtitleStyle || {}
}
function loadKeywordStyle(manifest) {
return loadAccountConfig(manifest).capcut?.keywordStyle || {}
}
function loadTransitions(manifest) {
return loadAccountConfig(manifest).capcut?.transitions || null
}
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
}
// ============================================================================
// 转场策略
// ============================================================================
function getTransition(item, index, totalCount, transitionConfig, allItems, timeline) {
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 }
if (index >= totalCount - 1) return { name: '', duration: 0 }
switch (strategy) {
case 'director': {
const ref = (item.directorRef || '').toLowerCase()
const byDirector = transitionConfig.byDirector || {}
return byDirector[ref] || defaultT
}
case 'rhythm': {
const rules = transitionConfig.byPosition || {}
if (index >= totalCount - 2) return rules.closing || defaultT
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
}
}
if (timeline && timeline.length > 0) {
const elapsed = timeline[index].start
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 {
if (index % 3 === 0) return rules.body || defaultT
}
return { name: '', duration: 0 }
}
case 'fixed':
default:
return defaultT
}
}
// ============================================================================
// 入场动画映射
// ============================================================================
const DIRECTOR_ANIMATIONS = {
tarantino: '动感放大',
kitano: '轻微放大',
fincher: '渐显',
}
function getAnimationForDirector(directorRef, defaultAnimation) {
if (!directorRef) return defaultAnimation
return DIRECTOR_ANIMATIONS[directorRef.toLowerCase()] || defaultAnimation
}
// ============================================================================
// Ken Burns 关键帧动画
// ============================================================================
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
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 },
)
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} 段)`)
}
// ============================================================================
// 添加图片
// ============================================================================
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, items, timeline)
const info = {
image_url: url,
width,
height,
start: tl.start,
end: tl.end,
duration: tl.duration,
transition: t.name,
transition_duration: t.duration,
}
const itemAnimation = getAnimationForDirector(item.directorRef, animation)
if (itemAnimation) {
const parts = itemAnimation.split('+').map(p => p.trim()).filter(Boolean)
const loopNames = ['缩放', '缩放 II', '回弹伸缩', '旋转伸缩']
const loopAnims = parts.filter(p => loopNames.includes(p))
const inAnims = parts.filter(p => !loopNames.includes(p))
if (loopAnims.length > 0) info.loop_animation = loopAnims.join('|')
if (inAnims.length > 0) info.in_animation = inAnims.join('|')
}
return info
})
console.log(` 一次性添加 ${imageInfos.length} 张图片...`)
const res = await api('add_images', {
draft_url: draftUrl,
image_infos: JSON.stringify(imageInfos),
alpha: 1, scale_x: 1, scale_y: 1,
transform_x: 0, transform_y: 0,
}, 300000)
const allSegmentIds = res.segment_ids || []
console.log(` 已添加 ${items.length} 张图片`)
return allSegmentIds
}
// ============================================================================
// 添加视频
// ============================================================================
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, timeline)
return {
video_url: item.videoUrl || (item.video ? path.resolve(inputDir, item.video) : null) || item.url || path.resolve(inputDir, item.file),
width,
height,
start: tl.start,
end: tl.end,
duration: tl.duration,
mask: '',
transition: t.name,
transition_duration: t.duration,
volume: item.volume || 1,
}
})
try {
const res = await api('add_videos', {
draft_url: draftUrl,
video_infos: JSON.stringify(videoInfos),
alpha: 1, scale_x: 1, scale_y: 1,
transform_x: 0, transform_y: 0,
scene_timelines: [],
})
console.log(` 已添加 ${items.length} 个视频片段(全量)`)
return res.segment_ids || []
} catch (err) {
if (!err.message.includes('504') && !err.message.includes('timeout')) throw err
console.log(` 全量提交超时,降级为分批添加...`)
}
const BATCH_SIZE = 3
const allSegmentIds = []
for (let i = 0; i < videoInfos.length; i += BATCH_SIZE) {
const batch = videoInfos.slice(i, i + BATCH_SIZE)
const batchNum = Math.floor(i / BATCH_SIZE) + 1
const totalBatches = Math.ceil(videoInfos.length / BATCH_SIZE)
console.log(` 分批 [${batchNum}/${totalBatches}] 添加 ${batch.length} 个片段...`)
const res = await api('add_videos', {
draft_url: draftUrl,
video_infos: JSON.stringify(batch),
alpha: 1, scale_x: 1, scale_y: 1,
transform_x: 0, transform_y: 0,
scene_timelines: [],
})
if (res.segment_ids) allSegmentIds.push(...res.segment_ids)
}
console.log(` 已添加 ${items.length} 个视频片段(分批)`)
return allSegmentIds
}
// ============================================================================
// 将 segment 写入视频轨道时间线slot
// 背景add_videos 只负责把视频加入素材库,不自动上时间线。
// 此函数在 add_videos 成功后调用,将每个 segment_id 写入第一个 video track。
// ============================================================================
async function addSlots(draftUrl, items, timeline) {
// 获取当前云端草稿的 draft_content获取第一个 video track 的 id
let draftData
try {
draftData = (await api('get_draft', { draft_url: draftUrl })).data || {}
} catch (err) {
console.log(' get_draft 不可用,切换本地写入模式')
return addSlotsLocally(draftUrl, items, timeline)
}
const tracks = draftData.tracks || []
const videoTrack = tracks.find(t => t.type === 'video')
if (!videoTrack) {
console.log(' 未找到 video track跳过 slot 写入')
return
}
// 构造 slot 数据(复用 buildSlot
const slots = []
for (let i = 0; i < items.length; i++) {
const segId = items[i].segmentId || items[i]._segmentId
if (!segId) continue
slots.push(buildSlot(segId, videoTrack.id, i, timeline[i]))
}
if (slots.length === 0) {
console.log(' 无有效 slot 数据,跳过')
return
}
// 通过 add_slots API 写入
try {
await api('add_slots', {
draft_url: draftUrl,
slots: JSON.stringify(slots),
})
console.log(` 已写入 ${slots.length} 个 slot 到视频轨道`)
} catch (err) {
console.log(` add_slots API 不可用: ${err.message},降级为本地写入`)
await addSlotsLocally(draftUrl, items, timeline, videoTrack.id)
}
}
// 直接写入本地 draft_content.json 的 slot
// options.draftId: 可选,直接指定 draftId优先使用否则从 draftUrl 提取
async function addSlotsLocally(draftUrl, items, timeline, trackId, options = {}) {
// 优先使用 options.draftId否则从 draftUrl 提取
let draftId = options.draftId || null
if (!draftId) {
try {
draftId = new URL(draftUrl).searchParams.get('draft_id')
} catch {
console.log(' 无法解析 draftUrl跳过本地 slot 写入')
return
}
}
if (!draftId) {
console.log(' 无法提取 draft_id跳过本地 slot 写入')
return
}
const jianyingPath = getConfig().jianyingDraftPath
const draftPath = path.join(jianyingPath, draftId, 'draft_content.json')
if (!fs.existsSync(draftPath)) {
console.log(` 本地草稿不存在: ${draftPath},跳过 slot 写入`)
return
}
let draft
try {
draft = JSON.parse(fs.readFileSync(draftPath, 'utf-8'))
} catch {
console.log(' draft_content.json 读取失败,跳过')
return
}
// 找到第一个 video track
const videoTrack = trackId
? draft.tracks.find(t => t.id === trackId)
: draft.tracks.find(t => t.type === 'video')
if (!videoTrack) {
console.log(' 未找到 video track跳过')
return
}
const slots = []
for (let i = 0; i < items.length; i++) {
const item = items[i]
const tl = timeline[i]
const segId = item.segmentId || item._segmentId
if (!segId) {
// 尝试从 materials.videos 匹配
const fname = item.video ? path.basename(item.video) : ''
const matVideo = (draft.materials.videos || []).find(v => {
const matFname = path.basename(v.path || '')
return fname && matFname.includes(fname.replace('videos/', ''))
})
if (matVideo) {
items[i]._segmentId = matVideo.id
slots.push(buildSlot(matVideo.id, videoTrack.id, i, tl, US))
}
} else {
slots.push(buildSlot(segId, videoTrack.id, i, tl, US))
}
}
if (slots.length > 0) {
videoTrack.slots = slots
draft.duration = timeline.length > 0 ? timeline[timeline.length - 1].end : 0
fs.writeFileSync(draftPath, JSON.stringify(draft, null, 2), 'utf-8')
console.log(` 已本地写入 ${slots.length} 个 slot 到视频轨道`)
// 触发剪映扫描
triggerDirScan(path.dirname(draftPath))
}
}
function buildSlot(segId, trackId, index, tl) {
return {
id: generateUUID(),
material_id: segId,
track_id: trackId,
render_index: index,
type: 'video',
common_property: {
start_time: tl.start,
source_timerange: { start: 0, duration: tl.duration },
target_timerange: { start: tl.start, duration: tl.duration },
is_avatar: false,
audio_fade: { fade_in_duration: 0, fade_out_duration: 0 },
volume: 1.0,
},
}
}
function generateUUID() {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
const r = Math.random() * 16 | 0
return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16).toUpperCase()
})
}
function triggerDirScan(dir) {
const { execFile } = require('child_process')
const tmp = dir + '.slot_tmp'
if (process.platform === 'darwin') {
execFile('rsync', ['-a', dir + '/', tmp], (err) => {
try { require('fs').rmSync(tmp, { recursive: true, force: true }) } catch {}
})
}
}
// ============================================================================
// 添加 TTS 配音
// ============================================================================
async function addVoiceover(draftUrl, inputDir, items, timeline, audioUrls = {}) {
// 优先使用 segments[] 逐段添加(精确对齐)
// 无 segments 时降级为旧的整段方式
const segmentsFlat = []
for (let i = 0; i < items.length; i++) {
const item = items[i]
const tl = timeline[i]
if (!item.audio) continue
if (item.segments && item.segments.length > 0) {
// 使用 segments 精确添加
for (const seg of item.segments) {
if (!seg.audio || seg.error) continue
const audioUrl = seg.audio.startsWith('http')
? seg.audio
: (audioUrls[seg.audio] || path.resolve(inputDir, seg.audio))
const segDurUs = Math.round(seg.duration * US)
const segStartUs = tl.start + Math.round(seg.startOffset * US)
segmentsFlat.push({
audio_url: audioUrl,
start: segStartUs,
end: segStartUs + segDurUs,
duration: segDurUs,
volume: 1.0,
})
}
} else {
// 降级:整段添加
const audioUrl = item.audio.startsWith('http')
? item.audio
: (audioUrls[item.audio] || path.resolve(inputDir, item.audio))
const audioDurUs = item.audioDuration ? item.audioDuration * US : tl.duration
segmentsFlat.push({
audio_url: audioUrl,
start: tl.start,
end: tl.start + audioDurUs,
duration: audioDurUs,
volume: 1.0,
})
}
}
if (segmentsFlat.length === 0) {
console.log(' 无 TTS 音频文件,跳过')
return
}
// 批量添加音频(同一轨道),失败时逐个兜底
let addedCount = 0
try {
await api('add_audios', {
draft_url: draftUrl,
audio_infos: JSON.stringify(segmentsFlat),
})
addedCount = segmentsFlat.length
} catch (err) {
console.log(` 批量添加音频失败 (${err.message.slice(0, 60)}),逐个添加...`)
for (const audioInfo of segmentsFlat) {
try {
await api('add_audios', {
draft_url: draftUrl,
audio_infos: JSON.stringify([audioInfo]),
})
addedCount++
} catch (e2) {
console.error(` 音频添加失败: ${e2.message.slice(0, 80)}`)
}
}
}
const ossCount = segmentsFlat.filter(a => a.audio_url.startsWith('http')).length
console.log(` 已添加 ${addedCount}/${segmentsFlat.length} 段 TTS 配音 (${ossCount} 段 OSS)`)
}
// ============================================================================
// 添加 BGM
// ============================================================================
async function addBGM(draftUrl, bgmUrl, totalDurationUs) {
let audioDuration = totalDurationUs
try {
const durRes = await api('get_audio_duration', { mp3_url: bgmUrl })
if (durRes.duration) audioDuration = durRes.duration
} catch (_) {}
const fadeIn = 500000
const fadeOut = 1000000
await api('add_audios', {
draft_url: draftUrl,
audio_infos: JSON.stringify([{
audio_url: bgmUrl,
duration: audioDuration,
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, fade 0.5s/1s)`)
}
// ============================================================================
// 添加字幕
// ============================================================================
async function addSubtitles(draftUrl, items, timeline, style = {}, split = false) {
const captions = []
const animStyle = {
inAnimation: style.inAnimation || '',
outAnimation: style.outAnimation || '',
inAnimDuration: style.inAnimationDuration || null,
outAnimDuration: style.outAnimationDuration || null,
}
for (let i = 0; i < items.length; i++) {
const item = items[i]
const text = item.script || item.text || item.caption || ''
if (!text) continue
const tl = timeline[i]
if (split && item.segments && item.segments.length > 0) {
// 精确字幕模式:使用 segments 实测时长,逐段添加字幕
for (const seg of item.segments) {
if (seg.error || !seg.text) continue
const segStartUs = tl.start + Math.round(seg.startOffset * US)
const segDurUs = Math.round(seg.duration * US)
const cap = {
start: segStartUs,
end: segStartUs + segDurUs,
text: seg.text,
}
applyAnimationProps(cap, animStyle)
captions.push(cap)
}
} else if (split) {
// 降级:按字符比例分配(无 segments 时)
const sentences = splitTextIntoSentences(text)
if (sentences.length === 0) continue
const totalDuration = tl.end - tl.start
const totalChars = sentences.reduce((sum, s) => sum + s.length, 0)
let currentTime = tl.start
sentences.forEach((sentence, idx) => {
const charRatio = sentence.length / totalChars
let duration = Math.round(totalDuration * charRatio)
if (idx === sentences.length - 1) {
duration = tl.end - currentTime
}
duration = Math.max(duration, 500000)
const cap = {
start: currentTime,
end: currentTime + duration,
text: sentence,
}
applyAnimationProps(cap, animStyle)
captions.push(cap)
currentTime += duration
})
} else {
const cap = {
start: tl.start,
end: tl.end,
text,
}
applyAnimationProps(cap, animStyle)
captions.push(cap)
}
}
if (captions.length === 0) {
console.log(' 无字幕内容,跳过')
return
}
const commonStyle = {
font: style.font || null,
font_size: style.fontSize || 15,
text_color: style.color || '#ffffff',
alignment: 1,
bold: style.bold || false,
italic: false,
underline: false,
has_shadow: style.hasShadow || false,
shadow_info: style.shadowAlpha ? {
shadow_alpha: style.shadowAlpha,
shadow_color: style.shadowColor || '#000000',
shadow_diffuse: 15,
shadow_distance: 5,
shadow_angle: -45,
} : undefined,
letter_spacing: style.letterSpacing || 0,
line_spacing: style.lineSpacing || 0,
alpha: style.alpha || 1,
scale_x: 1, scale_y: 1,
transform_x: 0,
transform_y: style.transformY || 0,
style_text: 0,
}
// 批量添加字幕(同一轨道),失败时逐条兜底
let addedCount = 0
try {
await api('add_captions', {
draft_url: draftUrl,
captions: JSON.stringify(captions),
...commonStyle,
})
addedCount = captions.length
} catch (err) {
console.log(` 批量添加字幕失败 (${err.message.slice(0, 60)}),逐条添加...`)
for (const cap of captions) {
try {
await api('add_captions', {
draft_url: draftUrl,
captions: JSON.stringify([cap]),
...commonStyle,
})
addedCount++
} catch (e2) {
console.error(` 字幕添加失败: ${e2.message.slice(0, 80)}`)
}
}
}
console.log(` 已添加 ${addedCount}/${captions.length} 条字幕${split ? ' (分句模式)' : ''} (字体: ${style.font || '默认'}, 动画: ${animStyle.inAnimation || '无'}${animStyle.outAnimation || '无'})`)
}
// ============================================================================
// 添加关键字氛围词
// ============================================================================
async function addKeywordOverlays(draftUrl, items, timeline, style = {}) {
const keywordItems = items.filter(item => item.keyword)
if (keywordItems.length === 0) {
console.log(' 无关键字,跳过')
return
}
const captions = []
for (let i = 0; i < items.length; i++) {
const item = items[i]
if (!item.keyword) continue
const tl = timeline[i]
const cap = {
start: tl.start,
end: tl.end,
text: item.keyword,
}
applyAnimationProps(cap, style)
captions.push(cap)
}
if (captions.length === 0) return
await api('add_captions', {
draft_url: draftUrl,
captions: JSON.stringify(captions),
font: style.font || null,
font_size: style.fontSize || 60,
text_color: style.color || '#FFFFFF',
alignment: 1,
bold: style.bold || false,
has_shadow: style.hasShadow || false,
shadow_info: style.shadowAlpha ? {
shadow_alpha: style.shadowAlpha,
shadow_color: style.shadowColor || '#000000',
shadow_diffuse: 15,
shadow_distance: 5,
shadow_angle: -45,
} : undefined,
alpha: style.alpha || 1,
scale_x: 1, scale_y: 1,
transform_x: 0,
transform_y: style.transformY || 0,
text_effect: style.textEffect || null,
})
console.log(` 已添加 ${captions.length} 个关键字氛围词 (效果: ${style.textEffect || '无'})`)
}
// ============================================================================
// 添加特效
// ============================================================================
async function addEffects(draftUrl, effectsStr, totalDurationUs) {
const effectNames = effectsStr.split(',').map(s => s.trim()).filter(Boolean)
const effectInfos = effectNames.map(name => ({
effect_title: name,
start: 0,
end: totalDurationUs,
}))
await api('add_effects', {
draft_url: draftUrl,
effect_infos: JSON.stringify(effectInfos),
})
console.log(` 已添加: ${effectNames.join(', ')}`)
}
// ============================================================================
// 添加滤镜
// ============================================================================
async function addFilter(draftUrl, filterStr, totalDurationUs) {
const [name, intensity] = filterStr.split(':')
await api('add_filters', {
draft_url: draftUrl,
filter_infos: JSON.stringify([{
filter_title: (name || '').trim(),
start: 0,
end: totalDurationUs,
intensity: parseFloat(intensity) || 50,
}]),
})
console.log(` 已添加: ${(name || '').trim()} 强度 ${intensity || 50}`)
}
module.exports = {
loadAccountConfig,
loadSubtitleStyle,
loadKeywordStyle,
loadKenBurns,
loadTransitions,
getTransition,
addImages,
addVideos,
addKenBurns,
addVoiceover,
addBGM,
addSubtitles,
addKeywordOverlays,
addSlots,
addSlotsLocally,
addEffects,
addFilter,
}