#!/usr/bin/env node /** * CapCut 成片组装脚本 * * 将图片/视频素材通过 CapCut Mate API 组装为草稿,同步到本地剪映。 * * 用法: * node capcut_assemble.js --input ./output/batch_xxx [选项] * * 配置: * 请运行 node setup.js 生成配置 * 同步方式: 纯 Node.js(sync-to-jianying.js),无需 Python/uv */ const axios = require('axios') const path = require('path') const fs = require('fs') 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 } 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 buildTimeline(items, defaultDurationUs) { // 音频为主轴,视频适配音频(短视频行业标准) // 有视频时长时取 max(不截断音频),无视频时用音频时长 let offset = 0 return items.map(item => { const audioDur = (item.duration != null) ? item.duration * US : 0 const videoDur = (item.videoDuration != null) ? item.videoDuration * US : 0 // 有视频:保证音频不被截断;无视频(图片模式):用音频时长 const dur = videoDur > 0 ? Math.max(audioDur, videoDur) : (audioDur || defaultDurationUs) const entry = { start: offset, end: offset + dur, duration: dur } offset += dur return entry }) } async function assemble(args) { const { input, manifest: manifestPath, mode = 'images', subtitles = 'true', voiceover = 'true', bgm, effects: effectsStr, filter: filterStr, format = '9:16', apiKey = '', duration = '4', animation = 'kenburns-zoom', } = 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('没有可用的素材文件') // 统一时间线:由 duration 驱动(TTS 音频时长)或 fallback 到固定时长 const timeline = buildTimeline(items, defaultDurationUs) const totalDurationUs = timeline.length > 0 ? timeline[timeline.length - 1].end : 0 const hasTTS = items.some(item => item.audio && item.duration != 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) } else { // 视频模式:确保所有 item 都有 videoUrl(CapCut API 需要公网 URL) 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.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) } // -- 添加 TTS 配音 -- step++; console.log(`[${step}/${totalSteps}] 添加 TTS 配音...`) if (voiceover === 'true' && hasTTS) { await addVoiceover(draftUrl, inputDir, items, timeline) } 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.text)) { await addSubtitles(draftUrl, items, timeline, subtitleStyle) } else { console.log(' 跳过') } // -- 添加特效 -- step++; console.log(`[${step}/${totalSteps}] 添加特效...`) if (effectsStr) { await addEffects(draftUrl, effectsStr, totalDurationUs) } else { console.log(' 跳过(未指定 --effects)') } // -- 添加滤镜 -- step++; console.log(`[${step}/${totalSteps}] 添加滤镜...`) if (filterStr) { await addFilter(draftUrl, filterStr, totalDurationUs) } 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 = '') { const imageInfos = items.map((item, i) => { const url = imgUrls[item.file] if (!url) throw new Error(`图片 ${item.file} 未上传成功,无法添加`) const tl = timeline[i] return { image_url: url, width, height, start: tl.start, end: tl.end, duration: tl.duration, animation: animation || '', transition: i > 0 ? '溶解' : '', transition_duration: 300000, } }) // 单次全量提交,所有图片在同一轨道 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 读取时长) // ============================================================================ async function addVideos(draftUrl, inputDir, items, timeline, width, height) { const videoInfos = items.map((item, i) => { const tl = timeline[i] 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: i > 0 ? '溶解' : '', transition_duration: 300000, 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) { // 收集需要上传的音频 const audioItems = items.filter(item => item.audio) if (audioItems.length === 0) { console.log(' 无 TTS 音频文件,跳过') return } // 上传本地音频到 OSS(已有的 URL 直接通过) console.log(' 上传 TTS 音频到 OSS...') const audioUrls = await batchUploadAudio(inputDir, items) const audioInfos = [] 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 配音`) } // ============================================================================ // 添加背景音乐 // ============================================================================ 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 {} } } // ============================================================================ // 添加字幕(支持关键词高亮 + 账号字幕风格) // ============================================================================ async function addSubtitles(draftUrl, items, timeline, style = {}) { 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.text || item.caption || '' if (!text) continue const tl = timeline[i] const keyword = item.keyword || '' const keywordColor = style.highlightColor || item.keywordColor || 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} 条字幕 (字体: ${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