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

337 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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)
})
}