Files
video-create/.claude/skills/video-from-script/scripts/capcut_assemble.js
sion123 f5d47ec5db feat(video-pipeline): 添加可配置的转场策略引擎和TTS语速支持
- 引入转场策略系统(`getTransition`),支持 `fixed`、`director`、`rhythm` 三种模式
- 根据账号配置文件动态读取转场配置(`loadTransitions`)
- 图片和视频轨道分别调用转场策略,替代原有的固定“闪白”转场
- 支持 `byPosition`(hook/body/keypoint/closing)和 `byDirector` 两种高级选择策略
- 图片动画支持 `loop_animation` 与 `in_animation` 解析(“缩放”、“弹入”等组合)
- TTS 合成新增 `rate` 字段(源自账号配置 `ttsRate`),默认语速调整为 1.1
- 默认动画类型从 `kenburns-zoom` 改为 `缩放`,适配中文 CapCut
2026-05-01 14:16:08 +08:00

1040 lines
35 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.
#!/usr/bin/env node
/**
* CapCut 成片组装脚本
*
* 将图片/视频素材通过 CapCut Mate API 组装为草稿,同步到本地剪映。
*
* 用法:
* node capcut_assemble.js --input ./output/batch_xxx [选项]
*
* 配置:
* 请运行 node setup.js 生成配置
* 同步方式: 纯 Node.jssync-to-jianying.js无需 Python/uv
*/
const axios = require('axios')
const path = require('path')
const fs = require('fs')
const { execFile } = require('child_process')
const { syncDraft, registerDraft, triggerDirectoryScan } = require('./sync-to-jianying')
// ============================================================================
// 配置
// ============================================================================
let _config = null
function getConfig() {
if (_config) return _config
const configPath = path.join(__dirname, '..', '..', 'config.json')
if (!fs.existsSync(configPath)) {
console.error('缺少配置文件: skills/config.json')
console.error('请运行 node setup.js 生成配置')
process.exit(1)
}
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'))
if (!config.jianyingDraftPath || !config.capcutMateDir || !config.capcutMateApiBase) {
console.error('config.json 需要填写 jianyingDraftPath、capcutMateDir 和 capcutMateApiBase')
process.exit(1)
}
_config = config
return _config
}
const BASE_URL = getConfig().capcutMateApiBase
const US = 1_000_000
// ============================================================================
// CapCut API 封装
// ============================================================================
async function api(endpoint, data = {}, timeout = 60000) {
const url = `${BASE_URL}/${endpoint}`
const method = endpoint === 'get_draft' ? 'get' : 'post'
try {
const res = method === 'get'
? await axios.get(url, { params: data, timeout })
: await axios.post(url, data, { timeout })
if (res.data.code !== undefined && res.data.code !== 0) {
throw new Error(`API [${endpoint}] 返回错误: ${res.data.message}`)
}
return res.data
} catch (err) {
if (err.response) {
throw new Error(`API [${endpoint}] HTTP ${err.response.status}: ${JSON.stringify(err.response.data)}`)
}
throw err
}
}
// ============================================================================
// CLI 参数
// ============================================================================
function parseArgs(argv) {
const args = {}
for (let i = 0; i < argv.length; i++) {
if (argv[i].startsWith('--')) {
const key = argv[i].slice(2)
const value = argv[i + 1]
if (value && !value.startsWith('--')) {
args[key] = value
i++
} else {
args[key] = true
}
}
}
return args
}
function getResolution(format) {
const map = {
'9:16': { width: 1080, height: 1920 },
'16:9': { width: 1920, height: 1080 },
'1:1': { width: 1080, height: 1080 },
'4:3': { width: 1440, height: 1080 },
}
return map[format] || map['9:16']
}
// ============================================================================
// OSS 上传
// ============================================================================
const ossUpload = require(path.join(__dirname, 'oss-upload'))
async function uploadToOSS(filePath) {
const { url } = await ossUpload.uploadFile(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) {
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}`)
}
}
return urls
}
function getAudioDurationSec(filePath) {
return new Promise((resolve) => {
execFile('ffprobe', [
'-v', 'quiet', '-show_entries', 'format=duration',
'-of', 'csv=p=0', filePath
], (err, stdout) => {
if (err) { resolve(null); return }
const dur = parseFloat(stdout.trim())
resolve(dur > 0 ? dur : null)
})
})
}
// ============================================================================
// 主流程
// ============================================================================
function buildTimeline(items, defaultDurationUs) {
// 音频为主轴视频调速适配≤2x 加速,>2x 截断)
let offset = 0
return items.map(item => {
const audioDur = (item.audioDuration != null) ? item.audioDuration * US : 0
const videoDur = (item.videoDuration != null) ? item.videoDuration * US : 0
// 无 TTS用视频时长或固定时长
if (audioDur <= 0) {
const dur = videoDur || defaultDurationUs
const entry = { start: offset, end: offset + dur, duration: dur, speed: 1 }
offset += dur
return entry
}
// 有 TTS音频时长为主轴
const dur = audioDur
const ratio = videoDur > 0 ? videoDur / audioDur : 1
// ≤2x: 加速到音频时长;>2x: 截断(视频只取前 audioDur 部分)
const speed = ratio <= 2 ? ratio : 1
const needAdjust = videoDur > audioDur + 100000 // 视频比音频长 0.1s 以上才需要调整
const entry = { start: offset, end: offset + dur, duration: dur, speed, needAdjust }
offset += dur
return entry
})
}
async function assemble(args) {
const {
input,
manifest: manifestPath,
mode = 'images',
subtitles = 'true',
splitCaptions = 'true',
voiceover = 'true',
bgm,
effects: effectsStr,
filter: filterStr,
format = '9:16',
apiKey = '',
duration = '4',
animation = '缩放',
localAudio = 'true',
} = args
if (!input) throw new Error('缺少 --input 参数')
const inputDir = path.resolve(input)
const manifestFile = manifestPath
? path.resolve(manifestPath)
: path.join(inputDir, 'manifest.json')
if (!fs.existsSync(manifestFile)) {
throw new Error(`找不到 manifest.json: ${manifestFile}`)
}
const manifest = JSON.parse(fs.readFileSync(manifestFile, 'utf-8'))
const { width, height } = getResolution(format)
const defaultDurationUs = parseFloat(duration) * US
// 过滤出实际存在的文件
const items = manifest.items.filter(item => {
if (item.url) return true // 视频模式可能用 URL
const filePath = path.join(inputDir, item.file)
return fs.existsSync(filePath)
})
if (items.length === 0) throw new Error('没有可用的素材文件')
// 用 ffprobe 测量实际音频/视频时长,替代 manifest 中的估计值
let audioMeasured = 0, videoMeasured = 0
for (const item of items) {
// 测量 TTS 音频实际时长
if (item.audio && !item.audio.startsWith('http')) {
const audioPath = path.isAbsolute(item.audio)
? item.audio
: path.resolve(inputDir, item.audio)
if (fs.existsSync(audioPath)) {
const actualDur = await getAudioDurationSec(audioPath)
if (actualDur != null) { item.audioDuration = actualDur; audioMeasured++ }
}
}
// 测量视频文件实际时长(生成器返回的是硬编码常量,不准确)
if (item.video) {
const videoPath = path.isAbsolute(item.video)
? item.video
: path.resolve(inputDir, item.video)
if (fs.existsSync(videoPath)) {
const actualDur = await getAudioDurationSec(videoPath) // ffprobe 对音视频通用
if (actualDur != null) { item.videoDuration = actualDur; videoMeasured++ }
}
}
}
if (audioMeasured > 0 || videoMeasured > 0) {
console.log(` 实际时长测量: 音频 ${audioMeasured} 个, 视频 ${videoMeasured}`)
}
const timeline = buildTimeline(items, defaultDurationUs)
const totalDurationUs = timeline.length > 0 ? timeline[timeline.length - 1].end : 0
const hasTTS = items.some(item => item.audio && item.audioDuration != null)
console.log(`\nCapCut 成片组装`)
console.log(` 模式: ${mode} 画幅: ${format} (${width}x${height})`)
console.log(` 时间线: ${hasTTS ? 'TTS音频驱动' : `固定${duration}s/段`} 总时长: ${(totalDurationUs / US).toFixed(1)}s`)
console.log(` 字幕: ${subtitles} 配音: ${voiceover} 动画: ${animation}`)
console.log(` 素材: ${items.length} 个可用\n`)
const steps = []
if (mode === 'images') steps.push('upload')
steps.push('draft', 'materials', 'voiceover', 'audio', 'subtitles', 'effects', 'filter', 'save', 'sync')
const totalSteps = steps.length
let step = 0
// -- 上传图片到 OSS优先使用 manifest 中已有的 URL --
let imgUrls = {}
if (mode === 'images') {
// 先从 manifest 收集已有 URL
const needUpload = []
for (const item of items) {
if (item.url && item.url.startsWith('http')) {
imgUrls[item.file] = item.url
} else {
needUpload.push(item.file)
}
}
if (needUpload.length > 0) {
step++; console.log(`[${step}/${totalSteps}] 上传图片到 OSS (${needUpload.length} 张需上传, ${Object.keys(imgUrls).length} 张已有URL)...`)
const uploaded = await batchUploadToOSS(inputDir, needUpload)
imgUrls = { ...imgUrls, ...uploaded }
} else {
step++; console.log(`[${step}/${totalSteps}] 所有图片已有 URL跳过上传`)
}
if (Object.keys(imgUrls).length === 0) throw new Error('所有图片上传失败')
console.log(` 成功: ${Object.keys(imgUrls).length}/${items.length}\n`)
}
// -- 创建草稿 --
step++; console.log(`[${step}/${totalSteps}] 创建草稿...`)
const draftRes = await api('create_draft', { width, height })
const draftUrl = draftRes.draft_url
const draftId = new URL(draftUrl).searchParams.get('draft_id')
console.log(` draft_id: ${draftId}\n`)
// -- 导入素材 --
step++; console.log(`[${step}/${totalSteps}] 导入素材...`)
if (mode === 'images') {
await addImages(draftUrl, items, imgUrls, timeline, width, height, animation, transitionConfig)
} else {
// 视频模式:调速 → 上传 OSS → 添加到草稿
// Step 1: ffmpeg 调速(在上传前,避免传两份)
let adjustedCount = 0
for (let i = 0; i < items.length; i++) {
const item = items[i]
const tl = timeline[i]
if (tl.needAdjust && item.video) {
const videoPath = path.resolve(inputDir, item.video)
const audioDur = tl.duration / US
const adjustedPath = await adjustVideoSpeed(videoPath, audioDur)
if (adjustedPath !== videoPath) {
item.video = path.relative(inputDir, adjustedPath)
item.videoDuration = audioDur
adjustedCount++
}
}
}
if (adjustedCount > 0) {
console.log(` 视频调速: ${adjustedCount}/${items.length}`)
}
// Step 2: 上传(已调速的)视频到 OSS
const missingUrl = items.filter(it => it.video && !it.videoUrl)
if (missingUrl.length > 0) {
const { uploadFile } = require('./oss-upload')
console.log(` 上传 ${missingUrl.length} 个视频到 OSS...`)
for (const item of missingUrl) {
const videoPath = path.resolve(inputDir, item.video)
try {
const { url } = await uploadFile(videoPath)
item.videoUrl = url
// 回写 manifest
if (manifestFile) {
try {
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)
if (mi) { mi.videoUrl = url; fs.writeFileSync(manifestFile, JSON.stringify(m, null, 2)) }
} catch (_) {}
}
} catch (err) {
console.log(` 视频上传失败: ${err.message}`)
}
}
}
await addVideos(draftUrl, inputDir, items, timeline, width, height, transitionConfig)
}
// -- 添加 TTS 配音 --
step++; console.log(`[${step}/${totalSteps}] 添加 TTS 配音...`)
if (voiceover === 'true' && hasTTS) {
await addVoiceover(draftUrl, inputDir, items, timeline, localAudio === 'true')
} else {
console.log(' 跳过(无 TTS 音频或未启用)')
}
// -- 添加 BGM --
step++; console.log(`[${step}/${totalSteps}] 添加背景音乐...`)
if (bgm) {
await addBGM(draftUrl, bgm, totalDurationUs)
} else {
console.log(' 跳过(未指定 --bgm')
}
// -- 读取账号字幕风格 --
const subtitleStyle = loadSubtitleStyle(manifest)
if (Object.keys(subtitleStyle).length > 0) {
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)) {
await addSubtitles(draftUrl, items, timeline, subtitleStyle, splitCaptions === 'true')
} else {
console.log(' 跳过')
}
// -- 添加特效 --
step++; console.log(`[${step}/${totalSteps}] 添加特效...`)
if (effectsStr) {
try {
await addEffects(draftUrl, effectsStr, totalDurationUs)
} catch (e) {
console.log(` 特效跳过: ${e.message}`)
}
} else {
console.log(' 跳过(未指定 --effects')
}
// -- 添加滤镜 --
step++; console.log(`[${step}/${totalSteps}] 添加滤镜...`)
if (filterStr) {
try {
await addFilter(draftUrl, filterStr, totalDurationUs)
} catch (e) {
console.log(` 滤镜跳过: ${e.message}`)
}
} else {
console.log(' 跳过(未指定 --filter')
}
// -- 保存草稿 --
step++; console.log(`[${step}/${totalSteps}] 保存草稿...`)
await api('save_draft', { draft_url: draftUrl })
console.log(' 已保存\n')
// -- 同步到本地剪映 --
step++; console.log(`[${step}/${totalSteps}] 同步到本地剪映...`)
await syncToLocalJianying(draftUrl, draftId, totalDurationUs)
console.log(' 同步完成\n')
// -- 云渲染(可选)--
if (apiKey) {
console.log('提交云渲染...')
await api('gen_video', { draft_url: draftUrl, apiKey })
console.log('渲染已提交,使用 gen_video_status 查询进度')
}
console.log(`\n成片组装完成`)
console.log(` 草稿ID: ${draftId}`)
console.log(` 总时长: ${(totalDurationUs / US).toFixed(1)}s`)
console.log(` 素材数: ${items.length}`)
console.log(` 时间线: ${hasTTS ? 'TTS音频驱动' : '固定时长'}`)
if (mode === 'videos' && subtitles === 'false') {
console.log(`\n >> 视频模式未加字幕,请在剪映中打开草稿 → 识别字幕 → 语音识别生成\n`)
}
}
// ============================================================================
// 添加图片(自动上传到 OSS
// ============================================================================
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)
// animation 解析:支持 "缩放" (group), "放大" (in), "渐显+缩小" (in+out)
const info = {
image_url: url,
width,
height,
start: tl.start,
end: tl.end,
duration: tl.duration,
transition: 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
})
// 单次全量提交,所有图片在同一轨道
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
}
// ============================================================================
// 添加视频(从 manifest 读取时长)
// ============================================================================
/**
* ffmpeg 调速:将视频调整为指定时长
* ratio <= 2x: 加速ratio > 2x: 截断
* 返回调整后的文件路径(调整失败则返回原路径)
*/
async function adjustVideoSpeed(videoPath, targetDurationSec) {
if (!fs.existsSync(videoPath)) return videoPath
return new Promise((resolve) => {
// 先获取视频时长
execFile('ffprobe', [
'-v', 'quiet', '-show_entries', 'format=duration',
'-of', 'csv=p=0', videoPath
], (err, stdout) => {
if (err) { resolve(videoPath); return }
const videoDur = parseFloat(stdout.trim())
if (!videoDur || videoDur <= 0 || videoDur <= targetDurationSec + 0.1) {
resolve(videoPath); return
}
const ratio = videoDur / targetDurationSec
const outPath = videoPath.replace(/(\.\w+)$/, '_adj$1')
if (ratio <= 2) {
// 加速setpts=PTS/speed, atempo=speed (音频变速)
const speed = ratio.toFixed(3)
const atempo = Math.min(speed, 2.0) // atempo 单次上限 2.0
execFile('ffmpeg', [
'-y', '-i', videoPath,
'-filter_complex', `setpts=PTS/${speed}${videoPath.endsWith('.mp4') ? `,atempo=${atempo}` : ''}`,
'-an', // 去掉音频(视频模式不需要原始音轨)
outPath
], { timeout: 30000 }, (err) => {
if (err) { console.log(` 调速失败,使用原始视频: ${err.message}`); resolve(videoPath); return }
console.log(` 调速: ${videoDur.toFixed(1)}s → ${targetDurationSec.toFixed(1)}s (${speed}x)`)
resolve(outPath)
})
} else {
// 截断:取前 targetDuration 秒
execFile('ffmpeg', [
'-y', '-i', videoPath,
'-t', String(targetDurationSec),
'-c', 'copy',
outPath
], { timeout: 30000 }, (err) => {
if (err) { console.log(` 截断失败,使用原始视频: ${err.message}`); resolve(videoPath); return }
console.log(` 截断: ${videoDur.toFixed(1)}s → ${targetDurationSec.toFixed(1)}s`)
resolve(outPath)
})
}
})
})
}
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,
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(` 全量提交超时,降级为分批添加...`)
}
// 504 回退:分批添加(每批 3 个,保持绝对时间不变)
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
}
// ============================================================================
// 音频上传(本地文件 → OSS 公网 URL
// ============================================================================
async function uploadAudioToOSS(filePath) {
try {
const oss = require(path.join(__dirname, 'oss-upload'))
const { url } = await oss.uploadFile(filePath)
return url
} catch (err) {
throw new Error(`音频上传 OSS 失败: ${err.message}`)
}
}
async function batchUploadAudio(inputDir, items) {
const urls = {}
for (const item of items) {
if (!item.audio || item.audio.startsWith('http')) {
if (item.audio) urls[item.audio] = item.audio
continue
}
// audio 可以是相对路径或绝对路径
const filePath = path.isAbsolute(item.audio)
? item.audio
: path.resolve(inputDir, item.audio)
if (!fs.existsSync(filePath)) {
console.error(` 音频文件不存在: ${filePath}`)
continue
}
try {
urls[item.audio] = await uploadAudioToOSS(filePath)
console.log(` 上传: ${path.basename(filePath)} -> OK`)
} catch (err) {
console.error(` 上传失败: ${path.basename(filePath)} - ${err.message}`)
}
}
return urls
}
// ============================================================================
// 添加 TTS 配音(每段音频按时间线排列)
// ============================================================================
async function addVoiceover(draftUrl, inputDir, items, timeline, localAudio = true) {
// 收集音频
const audioItems = items.filter(item => item.audio)
if (audioItems.length === 0) {
console.log(' 无 TTS 音频文件,跳过')
return
}
const audioInfos = []
if (localAudio) {
// 本地模式:直接用本地路径,不上传 OSS
for (let i = 0; i < items.length; i++) {
const item = items[i]
if (!item.audio) continue
const filePath = item.audio.startsWith('http')
? item.audio
: (path.isAbsolute(item.audio) ? item.audio : path.resolve(inputDir, item.audio))
if (!item.audio.startsWith('http') && !fs.existsSync(filePath)) {
console.error(` 音频文件不存在: ${filePath}`)
continue
}
const tl = timeline[i]
audioInfos.push({
audio_url: filePath,
start: tl.start,
end: tl.end,
duration: tl.duration,
volume: 1.0,
})
}
} else {
// 上传模式:先传 OSS 再用 URL
const audioUrls = await batchUploadAudio(inputDir, items)
for (let i = 0; i < items.length; i++) {
const item = items[i]
if (!item.audio) continue
const audioUrl = audioUrls[item.audio]
if (!audioUrl) continue
const tl = timeline[i]
audioInfos.push({
audio_url: audioUrl,
start: tl.start,
end: tl.end,
duration: tl.duration,
volume: 1.0,
})
}
}
if (audioInfos.length === 0) {
console.log(' 无可用音频,跳过配音')
return
}
await api('add_audios', {
draft_url: draftUrl,
audio_infos: JSON.stringify(audioInfos),
})
console.log(` 已添加 ${audioInfos.length} 段 TTS 配音 (${localAudio ? '本地路径' : 'OSS'})`)
}
// ============================================================================
// 添加背景音乐
// ============================================================================
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 (_) {
// 无法获取时长就用视频总时长
}
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,
}]),
})
console.log(` 已添加 BGM (${(audioDuration / US).toFixed(1)}s)`)
}
// ============================================================================
// 读取账号字幕风格配置
// ============================================================================
function loadSubtitleStyle(manifest) {
const account = manifest.account
if (!account) return {}
const scriptDir = __dirname
const accountFile = path.join(scriptDir, '..', '..', '..', 'accounts', account, 'account.json')
if (!fs.existsSync(accountFile)) return {}
try {
const accountData = JSON.parse(fs.readFileSync(accountFile, 'utf-8'))
return accountData.capcut?.subtitleStyle || {}
} 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 }
}
// ============================================================================
// 添加字幕(支持关键词高亮 + 账号字幕风格 + 分句切分)
// ============================================================================
/**
* 按标点符号切分文本为短句(去除所有标点符号)
*/
function splitTextIntoSentences(text) {
const sentenceEnders = /[。!?;]/
const clauseEnders = /[]/
const sentences = []
let current = ''
let chars = text.split('')
for (let i = 0; i < chars.length; i++) {
const char = chars[i]
current += char
if (sentenceEnders.test(char)) {
// 切分并去掉所有标点
sentences.push(current.trim().replace(/[。!?;,:、]/g, ''))
current = ''
} else if (clauseEnders.test(char) && current.length > 8) {
// 切分并去掉所有标点
sentences.push(current.trim().replace(/[。!?;,:、]/g, ''))
current = ''
}
}
// 处理剩余文本
if (current.trim()) {
sentences.push(current.trim().replace(/[。!?;,:、]/g, ''))
}
return sentences
}
async function addSubtitles(draftUrl, items, timeline, style = {}, split = false) {
const captions = []
// 从账号配置读取动画参数
const inAnimation = style.inAnimation || ''
const outAnimation = style.outAnimation || ''
const inAnimDuration = style.inAnimationDuration || null
const 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) {
// 分句模式:切分长文本
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
}
// 最小1秒避免太短
duration = Math.max(duration, 1000000) // 1秒 = 1000000微秒
const cap = {
start: currentTime,
end: currentTime + duration,
text: sentence,
keyword: '',
keyword_color: '',
}
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
captions.push(cap)
currentTime += duration
})
} else {
// 原始模式:一句字幕
const keyword = ''
const keywordColor = style.highlightColor || style.color || '#FFFFFF'
const cap = {
start: tl.start,
end: tl.end,
text,
keyword,
keyword_color: keyword ? keywordColor : '',
keyword_font_size: 18,
}
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
captions.push(cap)
}
}
if (captions.length === 0) {
console.log(' 无字幕内容,跳过')
return
}
await api('add_captions', {
draft_url: draftUrl,
captions: JSON.stringify(captions),
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,
})
console.log(` 已添加 ${captions.length} 条字幕${split ? ' (分句模式)' : ''} (字体: ${style.font || '默认'}, 动画: ${inAnimation || '无'}${outAnimation || '无'})`)
}
// ============================================================================
// 添加特效
// ============================================================================
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}`)
}
// ============================================================================
// 同步草稿到本地剪映
// ============================================================================
async function syncToLocalJianying(draftUrl, draftId, totalDurationUs) {
await syncDraft(draftUrl, { name: draftId })
registerDraft(draftId, draftId, totalDurationUs)
}
// ============================================================================
// 主入口
// ============================================================================
async function main() {
const args = parseArgs(process.argv.slice(2))
if (!args.input) {
console.log('用法: node capcut_assemble.js --input <目录> [选项]')
console.log('')
console.log('必填:')
console.log(' --input <dir> 素材目录(含 manifest.json')
console.log('')
console.log('选项:')
console.log(' --mode images|videos 素材类型(默认 images')
console.log(' --format 9:16 画幅比例')
console.log(' --duration 4 默认每段时长/秒无TTS时的fallback默认 4')
console.log(' --voiceover true|false 是否添加TTS配音轨道默认 true')
console.log(' --subtitles true|false 是否添加字幕(默认 true')
console.log(' --split-captions true|false 分句字幕模式(默认 false长句按标点切分')
console.log(' --bgm <url> 背景音乐 URL')
console.log(' --effects "名称1,名称2" 特效名称(逗号分隔)')
console.log(' --filter "名称:强度" 滤镜(强度 0-100')
console.log(' --apiKey <key> 云渲染 API Key可选')
console.log(' --manifest <path> manifest.json 路径')
console.log('')
console.log('时间线模式:')
console.log(' manifest.json 中每段包含 audio + duration → TTS音频驱动时间线')
console.log(' 无 audio/duration → 按 --duration 固定时长')
console.log('')
console.log('manifest.json 示例TTS驱动:')
console.log(' {"items":[{"file":"1.png","text":"文案","audio":"seg_1.mp3","duration":3.5}]}')
console.log('')
console.log('配置:')
console.log(' 请运行 node setup.js 生成配置')
process.exit(0)
}
await assemble(args)
}
if (require.main === module) {
main().catch(err => {
console.error(`\n错误: ${err.message}`)
process.exit(1)
})
}
module.exports = { assemble }