Files
video-create/.claude/skills/video-from-script/scripts/capcut_assemble.js
sion123 f2bc0df447 feat(video-pipeline): 支持滤镜和转场从账号配置继承
- 新增 Q18 滤镜和 Q19 转场字段到账号创建参考文档
- 重构账号配置加载函数为通用 `loadAccountConfig`,支持读取滤镜和转场
- `capcut_assemble.js` 支持 CLI 参数优先、账号配置兜底的滤镜/特效继承逻辑
- 模板 `account.json` 将闭幕转场从 "黑场" 修正为 "闪黑
2026-05-01 15:44:25 +08:00

1143 lines
40 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')
const { splitTextIntoSentences } = require('./lib/pipeline-utils')
// ============================================================================
// 配置
// ============================================================================
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 = '渐显+放大',
} = 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'))
// 从 account.json 自动继承 effects / filterCLI 参数优先)
let finalEffects = effectsStr
let finalFilter = filterStr
if (!finalEffects || !finalFilter) {
const accountData = loadAccountConfig(manifest)
if (!finalEffects && accountData.capcut?.effects?.length) {
finalEffects = accountData.capcut.effects.join(',')
}
if (!finalFilter && accountData.capcut?.filter) {
finalFilter = accountData.capcut.filter
}
}
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 音频实际时长(有 segments 时跳过audioDuration 已是精确累计值)
if (item.audio && !item.audio.startsWith('http') && !item.segments) {
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)
// -- 读取转场策略(在 addImages/addVideos 之前) --
const transitionConfig = loadTransitions(manifest)
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}`)
if (finalEffects) console.log(` 特效: ${finalEffects}`)
if (finalFilter) console.log(` 滤镜: ${finalFilter}`)
console.log(` 素材: ${items.length} 个可用\n`)
const steps = []
if (mode === 'images') steps.push('upload')
steps.push('draft', 'materials', 'audio_oss', 'voiceover', 'audio', 'subtitles', 'keywords', '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) {
console.log(` 上传 ${missingUrl.length} 个视频到 OSS...`)
for (const item of missingUrl) {
const videoPath = path.resolve(inputDir, item.video)
try {
const url = await uploadToOSS(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 音频到 OSS --
let audioUrls = {}
if (voiceover === 'true' && hasTTS) {
step++; console.log(`[${step}/${totalSteps}] 上传 TTS 音频到 OSS...`)
try {
audioUrls = await batchUploadAudio(inputDir, items)
console.log(` 成功: ${Object.keys(audioUrls).length} 段音频\n`)
// 回写 OSS URL 到 manifest避免重复上传
if (Object.keys(audioUrls).length > 0 && manifestFile) {
let changed = false
for (const item of manifest.items) {
if (item.audio && audioUrls[item.audio]) {
item.audio = audioUrls[item.audio]
changed = true
}
if (item.segments) {
for (const seg of item.segments) {
if (seg.audio && audioUrls[seg.audio]) {
seg.audio = audioUrls[seg.audio]
changed = true
}
}
}
}
if (changed) fs.writeFileSync(manifestFile, JSON.stringify(manifest, null, 2))
}
} catch (err) {
console.log(` OSS 上传失败,将尝试本地路径: ${err.message}\n`)
}
}
// -- 添加 TTS 配音 --
step++; console.log(`[${step}/${totalSteps}] 添加 TTS 配音...`)
if (voiceover === 'true' && hasTTS) {
await addVoiceover(draftUrl, inputDir, items, timeline, audioUrls)
} 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 : ''}`)
}
// -- 添加字幕 --
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}] 添加关键字氛围词...`)
const keywordStyle = loadKeywordStyle(manifest)
if (Object.keys(keywordStyle).length > 0 && items.some(i => i.keyword)) {
await addKeywordOverlays(draftUrl, items, timeline, keywordStyle)
} else {
console.log(' 跳过(无关键字或未配置 keywordStyle')
}
// -- 添加特效 --
step++; console.log(`[${step}/${totalSteps}] 添加特效...`)
if (finalEffects) {
try {
await addEffects(draftUrl, finalEffects, totalDurationUs)
} catch (e) {
console.log(` 特效跳过: ${e.message}`)
}
} else {
console.log(' 跳过(未配置特效)')
}
// -- 添加滤镜 --
step++; console.log(`[${step}/${totalSteps}] 添加滤镜...`)
if (finalFilter) {
try {
await addFilter(draftUrl, finalFilter, totalDurationUs)
} catch (e) {
console.log(` 滤镜跳过: ${e.message}`)
}
} else {
console.log(' 跳过(未配置滤镜)')
}
// -- 保存草稿 --
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('+').map(p => p.trim()).filter(Boolean)
const groupNames = ['缩放', '缩放 II']
const groupAnims = parts.filter(p => groupNames.includes(p))
const inAnims = parts.filter(p => !groupNames.includes(p))
if (groupAnims.length > 0) info.loop_animation = groupAnims.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
}
// ============================================================================
// 添加视频(从 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 batchUploadAudio(inputDir, items) {
const urls = {}
for (const item of items) {
// 上传 segments 中的每段音频
if (item.segments && item.segments.length > 1) {
for (const seg of item.segments) {
if (!seg.audio || seg.audio.startsWith('http') || urls[seg.audio]) continue
const filePath = path.isAbsolute(seg.audio)
? seg.audio
: path.resolve(inputDir, seg.audio)
if (!fs.existsSync(filePath)) {
console.error(` 音频文件不存在: ${filePath}`)
continue
}
try {
urls[seg.audio] = await uploadToOSS(filePath)
console.log(` 上传: ${path.basename(filePath)} -> OK`)
} catch (err) {
console.error(` 上传失败: ${path.basename(filePath)} - ${err.message}`)
}
}
}
// 上传 item.audio单段或 segments 的第一段)
if (!item.audio || item.audio.startsWith('http')) {
if (item.audio) urls[item.audio] = item.audio
continue
}
if (urls[item.audio]) continue
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 uploadToOSS(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, audioUrls = {}) {
// 收集音频
const audioItems = items.filter(item => item.audio || (item.segments && item.segments.length > 0))
if (audioItems.length === 0) {
console.log(' 无 TTS 音频文件,跳过')
return
}
const audioInfos = []
const resolveAudio = (relPath) => {
if (relPath.startsWith('http')) return relPath
if (audioUrls[relPath]) return audioUrls[relPath]
return path.isAbsolute(relPath) ? relPath : path.resolve(inputDir, relPath)
}
for (let i = 0; i < items.length; i++) {
const item = items[i]
const tl = timeline[i]
const segments = item.segments && item.segments.length > 1 ? item.segments : null
if (segments) {
// 多段音频:按 segment 逐段添加,使用精确时长
const totalSegDur = segments.reduce((sum, s) => sum + s.duration * US, 0)
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)
audioInfos.push({
audio_url: audioUrl,
start: currentTime,
end: currentTime + duration,
duration,
volume: 1.0,
})
currentTime += duration
}
} else if (item.audio) {
// 单段音频
const audioUrl = resolveAudio(item.audio)
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),
})
const ossCount = audioInfos.filter(a => a.audio_url.startsWith('http')).length
console.log(` 已添加 ${audioInfos.length} 段 TTS 配音 (${ossCount > 0 ? `${ossCount} 段 OSS + ` : ''}${audioInfos.length - ossCount} 段本地)`)
}
// ============================================================================
// 添加背景音乐
// ============================================================================
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 loadAccountConfig(manifest) {
const account = manifest.account
if (!account) return {}
const accountFile = path.join(__dirname, '..', '..', '..', '..', 'accounts', account, 'account.json')
if (!fs.existsSync(accountFile)) return {}
try { return JSON.parse(fs.readFileSync(accountFile, 'utf-8')) } catch { return {} }
}
function loadSubtitleStyle(manifest) {
return loadAccountConfig(manifest).capcut?.subtitleStyle || {}
}
function loadKeywordStyle(manifest) {
return loadAccountConfig(manifest).capcut?.keywordStyle || {}
}
// ============================================================================
// 添加关键字氛围词叠加(画面中央大字)
// ============================================================================
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,
}
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
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 || '无'})`)
}
function loadTransitions(manifest) {
return loadAccountConfig(manifest).capcut?.transitions || null
}
// ============================================================================
// 添加字幕(支持关键词高亮 + 账号字幕风格 + 分句切分)
// ============================================================================
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) {
// 分句模式:优先用 segmentsTTS 逐句生成的精确时长),回退到字数估算
const segments = item.segments && item.segments.length > 1 ? item.segments : null
if (segments) {
// 精确模式:用 segments 的实际音频时长
const totalSegDur = segments.reduce((sum, s) => sum + s.duration * US, 0)
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
captions.push(cap)
currentTime += duration
})
} else {
// 回退:字数权重估算
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, 1000000)
const cap = {
start: currentTime,
end: currentTime + duration,
text: sentence,
}
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 cap = {
start: tl.start,
end: tl.end,
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
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 分句字幕模式(默认 true按标点切分')
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 }