#!/usr/bin/env node /** * 同步 CapCut Mate 草稿到本地剪映(独立版) * * 从 API 获取草稿文件列表 → 下载到本地剪映目录 → 路径重写 → 远程素材本地化 → 注册 + 触发扫描 * 不依赖 Electron、不依赖 capcut-mate Python 环境。 * * 用法: * node sync-to-jianying.js [--name "草稿名称"] * * draft_url 格式: http://xxx/openapi/capcut-mate/v1/get_draft?draft_id=xxx */ const axios = require('axios') const path = require('path') const fs = require('fs') const { createWriteStream } = require('fs') const fsp = fs.promises const { execFile } = require('child_process') // ============================================================================ // 配置 // ============================================================================ function getConfig() { const configPath = path.join(__dirname, '..', '..', 'config.json') return JSON.parse(fs.readFileSync(configPath, 'utf-8')) } // ============================================================================ // 工具函数 // ============================================================================ function isHttpUrl(value) { if (!value || typeof value !== 'string') return false try { const parsed = new URL(value) return parsed.protocol === 'http:' || parsed.protocol === 'https:' } catch { return false } } function extractDraftId(url) { const match = url.match(/draft_id=([^&]+)/) return match ? match[1] : null } function winPath(p) { return p.replace(/\//g, '\\') } function getFileExtFromUrl(url, fallback = '.bin') { try { return path.extname(new URL(url).pathname) || fallback } catch { return fallback } } // ============================================================================ // 下载 // ============================================================================ async function downloadStream(url, filePath) { await fsp.mkdir(path.dirname(filePath), { recursive: true }) const res = await axios.get(url, { responseType: 'stream', timeout: 60000 }) if (res.status !== 200) throw new Error(`HTTP ${res.status}: ${url}`) return new Promise((resolve, reject) => { const writer = res.data.pipe(createWriteStream(filePath, { flags: 'w' })) writer.on('close', resolve) writer.on('error', reject) res.data.on('error', reject) }) } // ============================================================================ // 路径重写(核心逻辑来自 desktop-client/download.js) // ============================================================================ function updatePathValue(obj, key, targetDir, draftId) { const oldVal = obj[key] if (!oldVal || typeof oldVal !== 'string') return const idIndex = oldVal.indexOf(draftId) if (idIndex === -1) return const relativePath = oldVal.substring(idIndex).replace(/\//g, path.sep) const cleaned = relativePath.replace(draftId + path.sep, '') obj[key] = path.join(targetDir, cleaned) } function recursivelyUpdatePaths(obj, targetDir, draftId) { if (Array.isArray(obj)) { obj.forEach(item => recursivelyUpdatePaths(item, targetDir, draftId)); return } if (obj && typeof obj === 'object') { if (obj.path && typeof obj.path === 'string') updatePathValue(obj, 'path', targetDir, draftId) for (const key in obj) { if (obj.hasOwnProperty(key)) recursivelyUpdatePaths(obj[key], targetDir, draftId) } } } // ============================================================================ // 远程素材本地化(下载 http/https URL 素材到本地) // ============================================================================ async function localizeRemoteMaterials(materials, draftDir) { if (!materials || typeof materials !== 'object') return const supportedTypes = ['videos', 'audios'] const cache = new Map() // 收集所有需要下载的素材 const downloadTasks = [] for (const matType of supportedTypes) { const list = materials[matType] if (!Array.isArray(list)) continue for (const item of list) { if (!item || typeof item !== 'object') continue if (!isHttpUrl(item.path)) continue const subDir = matType === 'videos' ? (item.type === 'photo' ? 'images' : 'videos') : matType === 'audios' ? 'audios' : 'misc' const ext = getFileExtFromUrl(item.path, matType === 'audios' ? '.mp3' : '.mp4') const baseName = (item.material_name || item.name || item.id || Date.now()) + ext const localPath = path.join(draftDir, 'assets', subDir, baseName) if (!cache.has(item.path)) { cache.set(item.path, localPath) downloadTasks.push({ item, url: item.path, localPath, baseName }) } item.path = cache.get(item.path) } } if (downloadTasks.length === 0) return // 并行下载(最多 8 个并发) const CONCURRENCY = 8 console.log(` 素材本地化: ${downloadTasks.length} 个文件,${CONCURRENCY} 并发...`) for (let i = 0; i < downloadTasks.length; i += CONCURRENCY) { const batch = downloadTasks.slice(i, i + CONCURRENCY) await Promise.all(batch.map(async (task, j) => { try { await fsp.mkdir(path.dirname(task.localPath), { recursive: true }) await downloadStream(task.url, task.localPath) console.log(` [${i + j + 1}/${downloadTasks.length}] ${task.baseName} OK`) } catch (err) { console.error(` [${i + j + 1}/${downloadTasks.length}] ${task.baseName} FAIL: ${err.message}`) } })) } } // ============================================================================ // 注册到 root_meta_info.json // ============================================================================ function registerDraft(draftId, draftName, totalDurationUs) { const { jianyingDraftPath } = getConfig() const rootMetaPath = path.join(jianyingDraftPath, 'root_meta_info.json') const draftDir = path.join(jianyingDraftPath, draftId) const rootMeta = JSON.parse(fs.readFileSync(rootMetaPath, 'utf-8')) if (rootMeta.all_draft_store.some(d => d.draft_fold_path === winPath(draftDir))) { console.log(' 已注册,跳过') return } const now = Date.now() * 1000 rootMeta.all_draft_store.unshift({ cloud_draft_cover: false, cloud_draft_sync: false, draft_cloud_last_action_download: false, draft_cloud_purchase_info: '', draft_cloud_template_id: '', draft_cloud_tutorial_info: '', draft_cloud_videocut_purchase_info: '', draft_cover: winPath(path.join(draftDir, 'draft_cover.jpg')), draft_fold_path: winPath(draftDir), draft_id: draftId, draft_is_ai_shorts: false, draft_is_cloud_temp_draft: false, draft_is_invisible: false, draft_is_web_article_video: false, draft_json_file: winPath(path.join(draftDir, 'draft_content.json')), draft_name: draftName || draftId, draft_new_version: '', draft_root_path: winPath(jianyingDraftPath), draft_timeline_materials_size: 0, draft_type: '', draft_web_article_video_enter_from: '', streaming_edit_draft_ready: true, tm_draft_cloud_completed: '', tm_draft_cloud_entry_id: -1, tm_draft_cloud_modified: 0, tm_draft_cloud_parent_entry_id: -1, tm_draft_cloud_space_id: -1, tm_draft_cloud_user_id: -1, tm_draft_create: now, tm_draft_modified: now, tm_draft_removed: 0, tm_duration: totalDurationUs || 0, }) fs.writeFileSync(rootMetaPath, JSON.stringify(rootMeta, null, 4), 'utf-8') console.log(` 已注册: ${draftName || draftId}`) } // ============================================================================ // 触发剪映目录扫描(robocopy 技巧) // ============================================================================ function triggerDirectoryScan(targetDir) { if (!fs.existsSync(targetDir)) return const tmpDir = targetDir + '.tmp' if (process.platform === 'win32') { execFile('robocopy', [targetDir, tmpDir, '/E', '/COPY:DAT', '/R:1', '/W:1', '/NP', '/NJH', '/NJS'], { windowsHide: true }, (err) => { const code = err ? err.code : 0 if (code >= 8) console.log(` 扫描触发失败 (code ${code})`) else console.log(' 已触发剪映扫描') try { fs.rmSync(tmpDir, { recursive: true, force: true }) } catch {} }) } else if (process.platform === 'darwin') { execFile('rsync', ['-a', targetDir + '/', tmpDir], (err) => { if (!err) console.log(' 已触发剪映扫描') try { fs.rmSync(tmpDir, { recursive: true, force: true }) } catch {} }) } } // ============================================================================ // 主流程 // ============================================================================ async function syncDraft(draftUrl, options = {}) { const config = getConfig() const draftId = extractDraftId(draftUrl) if (!draftId) throw new Error('无法从 URL 提取 draft_id') const jianyingDraftPath = config.jianyingDraftPath const draftDir = path.join(jianyingDraftPath, draftId) console.log(`\n同步草稿到本地剪映`) console.log(` draft_id: ${draftId}`) console.log(` 目标目录: ${draftDir}\n`) // 1. 获取文件列表 console.log('[1/4] 获取文件列表...') const res = await axios.get(draftUrl, { timeout: 30000 }) if (res.data.code !== undefined && res.data.code !== 0) { throw new Error(`API 错误: ${res.data.message}`) } const fileUrls = res.data.files || [] console.log(` 获取 ${fileUrls.length} 个文件\n`) if (fileUrls.length === 0) { console.log(' 无文件,跳过') return } // 2. 下载文件 console.log('[2/4] 下载文件...') let success = 0, failed = 0 for (let i = 0; i < fileUrls.length; i++) { const fileUrl = fileUrls[i] try { // 解析本地路径 const urlObj = new URL(fileUrl) const idIndex = urlObj.pathname.indexOf(draftId) if (idIndex === -1) { failed++; continue } const relativePath = urlObj.pathname.substring(idIndex).replace(/\//g, path.sep) const cleaned = relativePath.replace(draftId + path.sep, '') const filePath = path.join(draftDir, cleaned) await fsp.mkdir(path.dirname(filePath), { recursive: true }) const fileName = path.basename(filePath) if (fileUrl.endsWith('.json')) { // JSON 文件:下载 → 路径重写 → 素材本地化 → 写入 const jsonRes = await axios.get(fileUrl, { timeout: 30000 }) const jsonData = jsonRes.data if (jsonData?.materials) { recursivelyUpdatePaths(jsonData.materials, draftDir, draftId) await localizeRemoteMaterials(jsonData.materials, draftDir) } await fsp.writeFile(filePath, JSON.stringify(jsonData, null, 2), 'utf-8') } else { // 二进制文件:流式下载 await downloadStream(fileUrl, filePath) } console.log(` [${i + 1}/${fileUrls.length}] OK: ${fileName}`) success++ } catch (err) { console.error(` [${i + 1}/${fileUrls.length}] FAIL: ${path.basename(fileUrl)} - ${err.message}`) failed++ } } console.log(` 下载完成: ${success}/${fileUrls.length}${failed ? `, 失败 ${failed}` : ''}\n`) // 3. 注册到剪映 console.log('[3/4] 注册到剪映...') registerDraft(draftId, options.name) console.log('') // 4. 触发扫描 console.log('[4/4] 触发剪映扫描...') triggerDirectoryScan(draftDir) console.log(`\n同步完成! 打开剪映即可看到草稿。\n`) } // ============================================================================ // 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 val = argv[i + 1] if (val && !val.startsWith('--')) { args[key] = val; i++ } else args[key] = true } else { if (!args.draftUrl) args.draftUrl = argv[i] } } return args } async function main() { const args = parseArgs(process.argv.slice(2)) if (!args.draftUrl) { console.log('用法: node sync-to-jianying.js [--name "草稿名称"]') console.log('') console.log('draft_url: http://xxx/openapi/capcut-mate/v1/get_draft?draft_id=xxx') process.exit(0) } await syncDraft(args.draftUrl, { name: args.name }) } module.exports = { syncDraft, registerDraft, triggerDirectoryScan } if (require.main === module) { main().catch(err => { console.error(`\n错误: ${err.message}`) process.exit(1) }) }