Files
video-create/.claude/skills/video-from-script/scripts/sync-to-jianying.js

337 lines
12 KiB
JavaScript
Raw Normal View History

#!/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)
})
}