Files
video-create/.claude/skills/video-from-script/scripts/capcut_assemble.js
sion123 6097a809bf refactor(video-pipeline): 移除 segments 机制,改为整段音频合成
移除 TTS 阶段逐句切分及 segments 数组逻辑,统一为整段音频合成。
CapCut 字幕切分由组装阶段按字符比例分配,简化音频上传、
时间线构建和字幕生成流程,减少冗余处理分支。
2026-05-02 02:31:55 +08:00

451 lines
16 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 组装为草稿,同步到本地剪映。
*
* 模块结构:
* capcut_assemble.js ← 编排器(本文件)+ CLI + 上传 + 同步
* lib/capcut-api.js ← 配置、API 封装、工具函数
* lib/capcut-timeline.js ← 时间线构建 + 视频调整策略
* lib/capcut-tracks.js ← 所有轨道操作(图片/视频/音频/字幕/特效)
*
* 用法:
* node capcut_assemble.js --input ./output/batch_xxx [选项]
*/
const path = require('path')
const fs = require('fs')
const { US, parseArgs, getResolution, getAudioDurationSec } = require('./lib/capcut-api')
const { buildTimeline, adjustVideoSpeed } = require('./lib/capcut-timeline')
const {
loadAccountConfig, loadSubtitleStyle,
loadKenBurns, loadTransitions,
addImages, addVideos, addKenBurns,
addVoiceover, addBGM,
addSubtitles,
addEffects, addFilter,
} = require('./lib/capcut-tracks')
const { saveManifest } = require('./lib/pipeline-utils')
const { syncDraft, registerDraft } = require('./sync-to-jianying')
// ============================================================================
// OSS 上传
// ============================================================================
const ossUpload = require(path.join(__dirname, 'oss-upload'))
async function uploadToOSS(filePath) {
const { url } = await ossUpload.uploadFile(filePath)
return url
}
async function batchUploadToOSS(inputDir, files, concurrency = 3) {
const urls = {}
const queue = [...files]
const workers = Array(Math.min(concurrency, queue.length)).fill(null).map(async () => {
while (queue.length > 0) {
const file = queue.shift()
if (!file) break
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}`)
}
}
})
await Promise.all(workers)
return urls
}
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
}
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
}
// ============================================================================
// 同步到本地剪映
// ============================================================================
async function syncToLocalJianying(draftUrl, draftId, totalDurationUs) {
await syncDraft(draftUrl, { name: draftId })
registerDraft(draftId, draftId, totalDurationUs)
}
// ============================================================================
// 主流程
// ============================================================================
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 = '',
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 / filter
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 items = manifest.items.filter(item => {
if (item.url) return true
if (item.video) return true
const filePath = path.join(inputDir, item.file)
return fs.existsSync(filePath)
})
if (items.length === 0) throw new Error('没有可用的素材文件')
// ffprobe 测量实际时长
let audioMeasured = 0, videoMeasured = 0
for (const item of items) {
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)
if (actualDur != null) { item.videoDuration = actualDur; videoMeasured++ }
}
}
}
if (audioMeasured > 0 || videoMeasured > 0) {
console.log(` 实际时长测量: 音频 ${audioMeasured} 个, 视频 ${videoMeasured}`)
}
const timeline = buildTimeline(items)
const totalDurationUs = timeline.length > 0 ? timeline[timeline.length - 1].end : 0
const hasTTS = items.some(item => item.audio && item.audioDuration != null)
// 时间轴诊断
for (let i = 0; i < items.length; i++) {
const item = items[i]
const tl = timeline[i]
if (tl.skip) { console.log(` [${i + 1}] 跳过(无音频)`); continue }
const audioDur = item.audioDuration || 0
const slotDur = tl.duration / US
const diff = slotDur - audioDur
const videoDur = (item.videoDuration || 0)
const stratInfo = tl.strategy && tl.strategy !== 'none' ? ` 策略=${tl.strategy}` : ''
const marker = Math.abs(diff) > 0.05 ? ' ⚠️ 不对齐' : ''
console.log(` [${i + 1}] 画面=${slotDur.toFixed(2)}s 音频=${audioDur.toFixed(2)}s 视频=${videoDur.toFixed(2)}s${stratInfo}${marker}`)
}
const transitionConfig = loadTransitions(manifest)
console.log(`\nCapCut 成片组装`)
console.log(` 模式: ${mode} 画幅: ${format} (${width}x${height})`)
console.log(` 时间线: ${hasTTS ? 'TTS音频驱动' : '视频原始时长'} 总时长: ${(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', 'kenburns', 'audio_oss', 'voiceover', 'audio', 'subtitles', 'effects', 'filter', 'save', 'sync')
const totalSteps = steps.length
let step = 0
// -- 上传图片到 OSS --
let imgUrls = {}
if (mode === 'images') {
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 require('./lib/capcut-api').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}] 导入素材...`)
let imageSegmentIds = []
if (mode === 'images') {
imageSegmentIds = await addImages(draftUrl, items, imgUrls, timeline, width, height, animation, transitionConfig)
} else {
// 视频模式:调整 → 上传 → 添加
let adjustedCount = 0
for (let i = 0; i < items.length; i++) {
const item = items[i]
const tl = timeline[i]
if (tl.strategy && tl.strategy !== 'none' && item.video) {
const videoPath = path.resolve(inputDir, item.video)
const audioDur = tl.duration / US
const adjustedPath = await adjustVideoSpeed(videoPath, audioDur, tl.strategy, tl.speed, tl.freezeExtra || 0)
if (adjustedPath !== videoPath) {
item.video = path.relative(inputDir, adjustedPath)
item.videoDuration = audioDur
adjustedCount++
}
}
}
if (adjustedCount > 0) {
console.log(` 视频调整: ${adjustedCount}/${items.length}`)
}
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
if (manifestFile) {
try {
const m = JSON.parse(fs.readFileSync(manifestFile, 'utf-8'))
const mi = m.items.find(i => i.id === item.id)
if (mi) { mi.videoUrl = url; saveManifest(manifestFile, m) }
} catch (_) {}
}
} catch (err) {
console.log(` 视频上传失败: ${err.message}`)
}
}
}
await addVideos(draftUrl, inputDir, items, timeline, width, height, transitionConfig)
}
// -- Ken Burns --
if (mode === 'images' && imageSegmentIds.length > 0) {
step++; console.log(`[${step}/${totalSteps}] 添加 Ken Burns 镜头动画...`)
await addKenBurns(draftUrl, imageSegmentIds, items, timeline, manifest)
}
// -- 上传 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`)
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 (changed) saveManifest(manifestFile, manifest)
}
} 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}] 添加特效...`)
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 require('./lib/capcut-api').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 require('./lib/capcut-api').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`)
}
}
// ============================================================================
// CLI 入口
// ============================================================================
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(' --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(' 图片模式: TTS 音频时长 = 画面时长,无音频则跳过')
console.log(' 视频模式: TTS 为主轴,视频通过以下策略适配:')
console.log(' 视频比音频长 → 加速(≤2x) 或 裁剪(>2x)')
console.log(' 视频比音频短 → 放缓(≥0.5x) 或 画面停顿(<0.5x)')
console.log(' 所有策略失败 → 兜底截断')
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 }