#!/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 { execFile } = require('child_process') 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) { // 音频为主轴,视频调速适配(≤2x 加速,>2x 截断) 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 // 无 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', 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 { // 视频模式:调速 → 上传 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) { 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) { try { await addEffects(draftUrl, effectsStr, totalDurationUs) } catch (e) { console.log(` 特效跳过: ${e.message}`) } } else { console.log(' 跳过(未指定 --effects)') } // -- 添加滤镜 -- step++; console.log(`[${step}/${totalSteps}] 添加滤镜...`) if (filterStr) { try { await addFilter(draftUrl, filterStr, totalDurationUs) } catch (e) { console.log(` 滤镜跳过: ${e.message}`) } } 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 读取时长) // ============================================================================ /** * 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) { 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 素材目录(含 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(' --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(' 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 }