字段重命名涉及所有文档、示例代码、提示词模板以及 slug 生成逻辑,确保管线各阶段(分镜、图生提示词、TTS、成片)一致使用 script 字段承载口播文案。 同时增强 `capcut_assemble.js` 的配音与字幕模块: - 配音支持本地路径模式(不上传 OSS),新增 `--split-captions` 参数启用分句字幕 - 分句字幕按标点切分长文本为短句,基于字数权重动态分配显示时长 - 新增 `update-captions-standalone.js` 独立工具,可在草稿已生成后单独更新字幕
159 lines
5.1 KiB
JavaScript
159 lines
5.1 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* 更新剪映草稿的字幕(独立版本)
|
||
* 用法:node lib/update-captions-standalone.js <draft_id> <manifest.json>
|
||
*/
|
||
|
||
const axios = require('axios')
|
||
const fs = require('fs')
|
||
const path = require('path')
|
||
const { syncDraft, registerDraft, triggerDirectoryScan } = require('../sync-to-jianying')
|
||
|
||
// 读取配置
|
||
function getConfig() {
|
||
const configPath = path.join(__dirname, '..', '..', '..', 'config.json')
|
||
if (!fs.existsSync(configPath)) {
|
||
console.error('❌ 缺少配置文件: skills/config.json')
|
||
process.exit(1)
|
||
}
|
||
return JSON.parse(fs.readFileSync(configPath, 'utf-8'))
|
||
}
|
||
|
||
const config = getConfig()
|
||
const BASE_URL = config.capcutMateApiBase
|
||
|
||
// CapCut API 封装
|
||
async function api(endpoint, data = {}, timeout = 60000) {
|
||
const url = `${BASE_URL}/${endpoint}`
|
||
const response = await axios.post(url, data, { timeout })
|
||
return response.data
|
||
}
|
||
|
||
// 主函数
|
||
async function updateCaptions(draftId, manifestPath) {
|
||
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'))
|
||
|
||
if (!manifest.split_captions || manifest.split_captions.length === 0) {
|
||
console.log('❌ manifest 中没有 split_captions 数据')
|
||
process.exit(1)
|
||
}
|
||
|
||
console.log(`\n📝 更新字幕:${draftId}`)
|
||
console.log(` 分句字幕:${manifest.split_captions.length} 条`)
|
||
|
||
// 获取草稿信息
|
||
const draftInfo = await api('get_draft_info', { draft_id: draftId })
|
||
const draftUrl = draftInfo.draft_url
|
||
|
||
// 读取字幕风格(accounts 目录在项目根目录,与 .claude 同级)
|
||
// 从脚本位置向上查找:scripts/lib -> scripts -> video-from-script -> skills -> .claude -> 项目根
|
||
let searchDir = __dirname
|
||
let accountPath = null
|
||
for (let i = 0; i < 6; i++) {
|
||
const testPath = path.join(searchDir, 'accounts', manifest.account, 'account.json')
|
||
if (fs.existsSync(testPath)) {
|
||
accountPath = testPath
|
||
break
|
||
}
|
||
searchDir = path.dirname(searchDir)
|
||
}
|
||
|
||
if (!accountPath) {
|
||
console.error(`❌ 找不到账号配置: accounts/${manifest.account}/account.json`)
|
||
console.error(` 搜索路径: ${searchDir}`)
|
||
process.exit(1)
|
||
}
|
||
|
||
const account = JSON.parse(fs.readFileSync(accountPath, 'utf8'))
|
||
const style = account.capcut?.subtitleStyle || {}
|
||
|
||
console.log(`\n[1/3] 清除旧字幕...`)
|
||
try {
|
||
// 尝试通过清除操作删除旧字幕
|
||
// CapCut Mate API 可能没有直接的 clear_captions,我们通过添加空字幕来覆盖
|
||
const oldCaptions = manifest.items.map(item => ({
|
||
start: 0,
|
||
end: 0,
|
||
text: '',
|
||
}))
|
||
await api('add_captions', {
|
||
draft_url: draftUrl,
|
||
captions: JSON.stringify(oldCaptions),
|
||
font: style.font || null,
|
||
font_size: style.fontSize || 15,
|
||
text_color: style.color || '#ffffff',
|
||
alignment: 1,
|
||
bold: style.bold || false,
|
||
})
|
||
console.log(` ✅ 旧字幕已清除`)
|
||
} catch (e) {
|
||
console.log(` ⚠️ 清除失败,继续添加新字幕: ${e.message}`)
|
||
}
|
||
|
||
console.log(`\n[2/3] 添加分句字幕...`)
|
||
|
||
const captions = manifest.split_captions.map(cap => ({
|
||
start: cap.start,
|
||
end: cap.end,
|
||
text: cap.text,
|
||
}))
|
||
|
||
await api('add_captions', {
|
||
draft_url: draftUrl,
|
||
captions: JSON.stringify(captions),
|
||
font: style.font || null,
|
||
font_size: style.fontSize || 15,
|
||
text_color: style.color || '#ffffff',
|
||
alignment: 1,
|
||
bold: style.bold || false,
|
||
has_shadow: style.hasShadow || false,
|
||
shadow_info: style.shadowAlpha ? {
|
||
shadow_alpha: style.shadowAlpha,
|
||
shadow_color: style.shadowColor || '#000000',
|
||
} : undefined,
|
||
transform_y: style.transformY || 0,
|
||
})
|
||
|
||
console.log(` ✅ 已添加 ${captions.length} 条字幕`)
|
||
|
||
console.log(`\n[3/3] 保存并同步...`)
|
||
await api('save_draft', { draft_url: draftUrl })
|
||
console.log(` ✅ 草稿已保存`)
|
||
|
||
// 同步到本地剪映(失败不影响字幕)
|
||
try {
|
||
await syncDraft(draftId, config.jianyingDraftPath)
|
||
await registerDraft(draftId, config.jianyingDraftPath)
|
||
triggerDirectoryScan(config.capcutMateDir)
|
||
console.log(` ✅ 同步完成`)
|
||
} catch (e) {
|
||
console.log(` ⚠️ 同步失败(字幕已保存,可手动在剪映云端打开): ${e.message}`)
|
||
}
|
||
|
||
console.log(` ✅ 同步完成`)
|
||
|
||
console.log(`\n✅ 字幕更新完成!`)
|
||
console.log(` 原字幕:16 条长句`)
|
||
console.log(` 新字幕:${captions.length} 条短句`)
|
||
console.log(` 平均时长:${(216.6 / captions.length).toFixed(1)} 秒/条`)
|
||
console.log(`\n🎬 打开剪映即可看到分句字幕效果`)
|
||
}
|
||
|
||
if (require.main === module) {
|
||
const draftId = process.argv[2]
|
||
const manifestPath = process.argv[3]
|
||
|
||
if (!draftId || !manifestPath) {
|
||
console.error('用法: node lib/update-captions-standalone.js <draft_id> <manifest.json>')
|
||
console.error('示例: node lib/update-captions-standalone.js 20260501020548bd30291f ../output/军事账号_20260501_002/manifest.json')
|
||
process.exit(1)
|
||
}
|
||
|
||
updateCaptions(draftId, manifestPath).catch(err => {
|
||
console.error(`\n❌ 错误: ${err.message}`)
|
||
process.exit(1)
|
||
})
|
||
}
|
||
|
||
module.exports = { updateCaptions }
|