#!/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, addSlotsLocally, addVoiceover, addBGM, addSubtitles, consolidateTracks, 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 (!urls[item.audio]) { const filePath = path.isAbsolute(item.audio) ? item.audio : path.resolve(inputDir, item.audio) if (fs.existsSync(filePath)) { try { urls[item.audio] = await uploadToOSS(filePath) console.log(` 上传: ${path.basename(filePath)} -> OK`) } catch (err) { console.error(` 上传失败: ${path.basename(filePath)} - ${err.message}`) } } } } else if (item.audio) { urls[item.audio] = item.audio } // 处理分段音频 if (item.segments && item.segments.length > 0) { for (const seg of item.segments) { if (!seg.audio || seg.error) continue if (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}`) } } } } 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('没有可用的素材文件') // 测量实际时长 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', 'consolidate') 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}`) } } } const segmentIds = await addVideos(draftUrl, inputDir, items, timeline, width, height, transitionConfig) // 将 segment_ids 附加到 items,供后续 addSlotsLocally 使用 if (segmentIds && segmentIds.length > 0) { items.forEach((item, i) => { item._segmentId = segmentIds[i] || null }) } } // -- 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') // -- 合并同类型轨道(TTS 逐条降级时每条独占一个轨道)-- step++; console.log(`[${step}/${totalSteps}] 合并同类型轨道...`) consolidateTracks(draftId) // -- 视频轨道 slot 写入(在 syncToLocalJianying 之后执行,此时本地草稿文件已存在)-- if (mode !== 'images') { step++; console.log(`[${step}/${totalSteps}] 写入视频轨道时间线...`) await addSlotsLocally(draftUrl, items, timeline, null, { draftId }) 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 素材目录(含 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') console.log(' --effects "名称1,名称2" 特效名称(逗号分隔)') console.log(' --filter "名称:强度" 滤镜(强度 0-100)') console.log(' --apiKey 云渲染 API Key(可选)') console.log(' --manifest 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 }