337 lines
12 KiB
JavaScript
337 lines
12 KiB
JavaScript
#!/usr/bin/env node
|
||
|
||
/**
|
||
* 同步 CapCut Mate 草稿到本地剪映(独立版)
|
||
*
|
||
* 从 API 获取草稿文件列表 → 下载到本地剪映目录 → 路径重写 → 远程素材本地化 → 注册 + 触发扫描
|
||
* 不依赖 Electron、不依赖 capcut-mate Python 环境。
|
||
*
|
||
* 用法:
|
||
* node sync-to-jianying.js <draft_url> [--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 <draft_url> [--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)
|
||
})
|
||
}
|