Files
video-create/.claude/skills/video-from-script/scripts/capcut_assemble.js

489 lines
18 KiB
JavaScript
Raw Normal View History

#!/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.segments && item.segments.length > 0) {
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}`)
}
}
2026-04-29 22:47:59 +08:00
}
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.segments && item.segments.length > 0) {
for (const seg of item.segments) {
if (!seg.audio || seg.audio.startsWith('http')) continue
const audioPath = path.isAbsolute(seg.audio)
? seg.audio
: path.resolve(inputDir, seg.audio)
if (!fs.existsSync(audioPath)) continue
const actualDur = await getAudioDurationSec(audioPath)
if (actualDur != null) { seg.duration = actualDur; audioMeasured++ }
}
} else 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.segments
? item.segments.reduce((s, seg) => s + (seg.duration || 0), 0)
: (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 {
// 视频模式:调整 → 上传 → 添加
2026-04-29 22:47:59 +08:00
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) {
2026-04-29 22:47:59 +08:00
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)
2026-04-29 22:47:59 +08:00
if (adjustedPath !== videoPath) {
item.video = path.relative(inputDir, adjustedPath)
item.videoDuration = audioDur
adjustedCount++
}
}
}
if (adjustedCount > 0) {
console.log(` 视频调整: ${adjustedCount}/${items.length}`)
2026-04-29 22:47:59 +08:00
}
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 (item.segments) {
for (const seg of item.segments) {
if (seg.audio && audioUrls[seg.audio]) {
seg.audio = audioUrls[seg.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) {
2026-04-29 22:47:59 +08:00
try {
await addEffects(draftUrl, finalEffects, totalDurationUs)
2026-04-29 22:47:59 +08:00
} catch (e) {
console.log(` 特效跳过: ${e.message}`)
}
} else {
console.log(' 跳过(未配置特效)')
}
// -- 滤镜 --
step++; console.log(`[${step}/${totalSteps}] 添加滤镜...`)
if (finalFilter) {
2026-04-29 22:47:59 +08:00
try {
await addFilter(draftUrl, finalFilter, totalDurationUs)
2026-04-29 22:47:59 +08:00
} 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)
}
2026-04-29 22:47:59 +08:00
if (require.main === module) {
main().catch(err => {
console.error(`\n错误: ${err.message}`)
process.exit(1)
})
}
module.exports = { assemble }