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)
|
|||
|
|
})
|
|||
|
|
}
|