feat(video-pipeline): 将 narration 字段统一重命名为 script

字段重命名涉及所有文档、示例代码、提示词模板以及 slug 生成逻辑,确保管线各阶段(分镜、图生提示词、TTS、成片)一致使用 script 字段承载口播文案。

同时增强 `capcut_assemble.js` 的配音与字幕模块:
- 配音支持本地路径模式(不上传 OSS),新增 `--split-captions` 参数启用分句字幕
- 分句字幕按标点切分长文本为短句,基于字数权重动态分配显示时长
- 新增 `update-captions-standalone.js` 独立工具,可在草稿已生成后单独更新字幕
This commit is contained in:
2026-05-01 02:48:37 +08:00
parent c878abc39b
commit 96f00ce7fd
9 changed files with 346 additions and 86 deletions

View File

@@ -120,7 +120,7 @@ Step 1: 分镜脚本生成(子 Agent 执行)
- 主 Agent 将用户文案 + 模板交给子 Agent
- 子 Agent 按模板要求输出分镜表 JSON
```json
[{"id":1,"shotDesc":"英文画面描述40-80词","narration":"中文口播旁白≤22字","duration":5,"directorRef":"tarantino"}]
[{"id":1,"shotDesc":"英文画面描述40-80词","script":"中文口播文案≤22字","duration":5,"directorRef":"tarantino"}]
```
- 主 Agent 审查分镜表时长合理、隐性动势完整、directorRef 已填)
- 展示给用户确认,确认后进入 Step 2-A
@@ -128,15 +128,15 @@ Step 1: 分镜脚本生成(子 Agent 执行)
Step 2-A: 生成图片提示词(子 Agent 执行)
- 读取 account.json 中的 imageStylePrompt 字段,定位图片提示词模板(如 prompts/图片提示词.md
- 子 Agent 为每个 shot 生成 imagePrompt
- 入参shotDesc + narration(情绪参考)+ directorRef光影策略+ 目标模型
- 入参shotDesc + script(情绪参考)+ directorRef光影策略+ 目标模型
- 出参imagePrompt可直接送给图片模型的英文提示词
- 主 Agent 审查 imagePrompt 质量shotDesc 内容完整保留、光影词库对应 directorRef
Step 2-B: 生成静态分镜图 + Manifest 初始化
- 组装 items 并初始化 manifest**不含 videoPrompt**
```bash
node pipeline.js init --account <id> --mode <single|framePair> \
--items '[{"shotDesc":"...","narration":"...","duration":5,"imagePrompt":"...","directorRef":"tarantino"}]'
node scripts/pipeline.js init --account <id> --mode <single|framePair> \
--items '[{"shotDesc":"...","script":"...","duration":5,"imagePrompt":"...","directorRef":"tarantino"}]'
```
- 脚本自动从 account.json 继承imageModel、videoModel、format、references
- 所有 item.confirmed = false
@@ -147,7 +147,7 @@ Step 2-B: 生成静态分镜图 + Manifest 初始化
Step 2-C: 人工确认(可选卡点)
- 展示所有分镜图给用户
- 用户可:确认全部 / 替换 MJ 候选图(改 item.file = item.candidates[N] / 删除不合格 item / 跳过确认直接继续
- 用户确认后:`node pipeline.js confirm --manifest <path> --all`
- 用户确认后:`node scripts/pipeline.js confirm --manifest <path> --all`
- 跳过确认时:批量设置 `confirmed = true`,直接进入 Step 3
Step 3-A: 生成视频提示词(子 Agent 执行)
@@ -165,7 +165,7 @@ Step 3-B: 生成视频片段
Step 4: TTS + 成片
- 跑 tts + assemble 阶段:`pipeline.js run --manifest <path> --phase tts,assemble`
- TTS 使用 narration 字段(口播旁白)
- TTS 使用 script 字段(口播旁白)
- 检查字幕准确、BGM 不盖配音
```
@@ -175,33 +175,33 @@ Step 4: TTS + 成片
```bash
# 创建账号Step 0首次使用时
node pipeline.js create-account --id <id> --name <名称> \
node scripts/pipeline.js create-account --id <id> --name <名称> \
--desc <描述> --video-model veo3-fast --references ./ref1.png,./ref2.png
# 校验账号完整性
node pipeline.js validate-account --account <id>
node scripts/pipeline.js validate-account --account <id>
# 初始化 manifestStep 2-B 使用AI 只提供创意内容,不含 videoPrompt
node pipeline.js init --account <id> --mode <single|framePair> \
--items '[{"shotDesc":"...","narration":"...","duration":5,"imagePrompt":"...","directorRef":"tarantino"}]'
node scripts/pipeline.js init --account <id> --mode <single|framePair> \
--items '[{"shotDesc":"...","script":"...","duration":5,"imagePrompt":"...","directorRef":"tarantino"}]'
# 也可从文件读取 items适合大量数据
node pipeline.js init --account <id> --mode single --items-file ./items.json
node scripts/pipeline.js init --account <id> --mode single --items-file ./items.json
# 校验 manifest 完整性
node pipeline.js validate --manifest <path>
node scripts/pipeline.js validate --manifest <path>
# 人工确认分镜图Step 2-C可选跳过时 Agent 批量设置 confirmed=true
node pipeline.js confirm --manifest <path> --all
node scripts/pipeline.js confirm --manifest <path> --all
# 跑指定阶段
node pipeline.js run --manifest <path> --phase images
node pipeline.js run --manifest <path> --phase upload,videos
node scripts/pipeline.js run --manifest <path> --phase images
node scripts/pipeline.js run --manifest <path> --phase upload,videos
# 断点续跑(跳过已完成阶段和 item
node pipeline.js run --manifest <path> --resume
node scripts/pipeline.js run --manifest <path> --resume
# 查看进度
node pipeline.js status --manifest <path>
node scripts/pipeline.js status --manifest <path>
```
**阶段**: `images``upload``videos``tts``assemble`
@@ -335,7 +335,7 @@ node kling-video-generator.js --image <url> --prompt <prompt> -o ./videos
output/{name}_{YYYYMMDD}_{NNN}/
├── manifest.json # 主清单(贯穿全流程)
├── prompts.txt # 原始提示词存档
├── images/ # scene_{NN}_{slug}.jpegslug 从 narration/shotDesc 派生,首尾帧加 _last 后缀)
├── images/ # scene_{NN}_{slug}.jpegslug 从 script/shotDesc 派生,首尾帧加 _last 后缀)
├── videos/ # scene_{NN}_{slug}.mp4与图片对应
└── urls.json # OSS 公网 URL 映射
```

View File

@@ -86,7 +86,7 @@ accounts/ # 项目根目录下
| 文件 | 用途 | Step |
|------|------|------|
| `分镜.md` | 口播文案 → 分镜表shotDesc/narration/duration/directorRef | Step 1 |
| `分镜.md` | 口播文案 → 分镜表shotDesc/script/duration/directorRef | Step 1 |
| `图片提示词.md` | shotDesc + directorRef → imagePrompt | Step 2-A |
| `视频提示词.md` | shotDesc + directorRef → videoPrompt | Step 3-A |

View File

@@ -2,7 +2,7 @@
> `pipeline.js init` 创建Pipeline 执行Agent 审查。
>
> **禁止 AI 手写 manifest.json**,必须通过 `pipeline.js init` 初始化。脚本从 account.json 自动继承结构字段AI 只提供创意内容items 的 shotDesc/narration/imagePrompt 等)。
> **禁止 AI 手写 manifest.json**,必须通过 `pipeline.js init` 初始化。脚本从 account.json 自动继承结构字段AI 只提供创意内容items 的 shotDesc/script/imagePrompt 等)。
---
@@ -10,18 +10,18 @@
```bash
# Step 2-A 生成 imagePrompt 后,通过脚本初始化(不含 videoPrompt
node pipeline.js init --account 军事账号 --mode single \
--items '[{"shotDesc":"英文画面描述","narration":"中文口播旁白","duration":5,"imagePrompt":"English prompt","directorRef":"tarantino"}]'
node scripts/pipeline.js init --account 军事账号 --mode single \
--items '[{"shotDesc":"英文画面描述","script":"中文口播文案","duration":5,"imagePrompt":"English prompt","directorRef":"tarantino"}]'
# 或从文件读取
node pipeline.js init --account 军事账号 --mode single --items-file ./items.json
node scripts/pipeline.js init --account 军事账号 --mode single --items-file ./items.json
# Step 2-C 人工确认
node pipeline.js confirm --manifest <path> --all
node pipeline.js confirm --manifest <path> --items 1,3,5
node scripts/pipeline.js confirm --manifest <path> --all
node scripts/pipeline.js confirm --manifest <path> --items 1,3,5
# 校验已有 manifest
node pipeline.js validate --manifest <path>
node scripts/pipeline.js validate --manifest <path>
```
---
@@ -58,7 +58,7 @@ node pipeline.js validate --manifest <path>
|------|------|
| `status` | 固定写 `"pending"` |
| `shotDesc` | 英文分镜描述含隐性动势40-80词 |
| `narration` | **该段的完整原文案**(不提炼,保留论证、例子、细节)|
| `script` | **该段的完整原文案**(不提炼,保留论证、例子、细节)|
| `duration` | 计划视频时长(秒),来自分镜阶段 |
| `imagePrompt` | 英文画面描述(给 Gemini/MJStep 2-A 生成 |
| `directorRef` | 导演构图参考tarantino / kitano / fincher三层透传 |
@@ -90,7 +90,7 @@ node pipeline.js validate --manifest <path>
- MJ 换选:`item.file = item.candidates[2]`
- 删除不合格 item直接从 items 数组移除,重新跑 `--phase images`
- 调整 prompt 重跑:改 `imagePrompt`status 改回 `pending`
- 人工确认:`node pipeline.js confirm --manifest <path> --all`
- 人工确认:`node scripts/pipeline.js confirm --manifest <path> --all`
---
@@ -115,7 +115,7 @@ Agent **不需要记住这些条件**pipeline 内部自动匹配。仅供理
| images | `status=pending` + 有 `imagePrompt` |
| upload | `status=done` + 有 `file` + 无 `url` |
| videos | `status=done` + `confirmed=true` + 有 `url` + 有 `videoPrompt` + 无 `video` |
| tts | `status=done` + 有 `narration`(回退 `text` + 无 `audio` |
| tts | `status=done` + 有 `script`(回退 `text` + 无 `audio` |
### pipeline.phases 整体状态
@@ -137,7 +137,7 @@ Agent **不需要记住这些条件**pipeline 内部自动匹配。仅供理
```bash
# 只改 prompt 不改图片风格 → 重试即可
node pipeline.js run --manifest <path> --phase images --retry-failed
node scripts/pipeline.js run --manifest <path> --phase images --retry-failed
# 需要换 prompt → 先改 item.imagePrompt再重试
# (改完后跑上面同一条命令)
@@ -147,7 +147,7 @@ node pipeline.js run --manifest <path> --phase images --retry-failed
```bash
# API 临时故障、网络超时 → 直接重试
node pipeline.js run --manifest <path> --phase videos --retry-failed
node scripts/pipeline.js run --manifest <path> --phase videos --retry-failed
# 提示词问题 → 先改 item.videoPrompt再重试
# (改完后跑上面同一条命令)
@@ -158,7 +158,7 @@ node pipeline.js run --manifest <path> --phase videos --retry-failed
**全阶段重试**
```bash
node pipeline.js run --manifest <path> --retry-failed
node scripts/pipeline.js run --manifest <path> --retry-failed
```
### `--retry-failed` 内部行为

View File

@@ -171,6 +171,7 @@ async function assemble(args) {
manifest: manifestPath,
mode = 'images',
subtitles = 'true',
splitCaptions = 'true',
voiceover = 'true',
bgm,
effects: effectsStr,
@@ -179,7 +180,7 @@ async function assemble(args) {
apiKey = '',
duration = '4',
animation = 'kenburns-zoom',
localAudio = 'false',
localAudio = 'true',
} = args
if (!input) throw new Error('缺少 --input 参数')
@@ -357,7 +358,7 @@ async function assemble(args) {
// -- 添加字幕 --
step++; console.log(`[${step}/${totalSteps}] 添加字幕...`)
if (subtitles === 'true' && items.some(i => i.script || i.text)) {
await addSubtitles(draftUrl, items, timeline, subtitleStyle)
await addSubtitles(draftUrl, items, timeline, subtitleStyle, splitCaptions === 'true')
} else {
console.log(' 跳过')
}
@@ -607,40 +608,63 @@ async function batchUploadAudio(inputDir, items) {
// 添加 TTS 配音(每段音频按时间线排列)
// ============================================================================
async function addVoiceover(draftUrl, inputDir, items, timeline, localAudio = false) {
// 收集需要上传的音频
async function addVoiceover(draftUrl, inputDir, items, timeline, localAudio = true) {
// 收集音频
const audioItems = items.filter(item => item.audio)
if (audioItems.length === 0) {
console.log(' 无 TTS 音频文件,跳过')
return
}
// 上传本地音频到 OSS已有的 URL 直接通过)
// 根据 localAudio 参数决定是否上传
const audioUrls = localAudio
? {} // 本地模式:不上传,使用本地路径
: await batchUploadAudio(inputDir, items)
const audioInfos = []
for (let i = 0; i < items.length; i++) {
const item = items[i]
if (!item.audio) continue
const audioUrl = audioUrls[item.audio]
if (!audioUrl) continue
if (localAudio) {
// 本地模式:直接用本地路径,不上传 OSS
for (let i = 0; i < items.length; i++) {
const item = items[i]
if (!item.audio) continue
const tl = timeline[i]
audioInfos.push({
audio_url: audioUrl,
start: tl.start,
end: tl.end,
duration: tl.duration,
volume: 1.0,
})
const filePath = item.audio.startsWith('http')
? item.audio
: (path.isAbsolute(item.audio) ? item.audio : path.resolve(inputDir, item.audio))
if (!item.audio.startsWith('http') && !fs.existsSync(filePath)) {
console.error(` 音频文件不存在: ${filePath}`)
continue
}
const tl = timeline[i]
audioInfos.push({
audio_url: filePath,
start: tl.start,
end: tl.end,
duration: tl.duration,
volume: 1.0,
})
}
} else {
// 上传模式:先传 OSS 再用 URL
const audioUrls = await batchUploadAudio(inputDir, items)
for (let i = 0; i < items.length; i++) {
const item = items[i]
if (!item.audio) continue
const audioUrl = audioUrls[item.audio]
if (!audioUrl) continue
const tl = timeline[i]
audioInfos.push({
audio_url: audioUrl,
start: tl.start,
end: tl.end,
duration: tl.duration,
volume: 1.0,
})
}
}
if (audioInfos.length === 0) {
console.log(' 所有音频上传失败,跳过配音')
console.log(' 无可用音频,跳过配音')
return
}
@@ -648,7 +672,7 @@ async function addVoiceover(draftUrl, inputDir, items, timeline, localAudio = fa
draft_url: draftUrl,
audio_infos: JSON.stringify(audioInfos),
})
console.log(` 已添加 ${audioInfos.length} 段 TTS 配音`)
console.log(` 已添加 ${audioInfos.length} 段 TTS 配音 (${localAudio ? '本地路径' : 'OSS'})`)
}
// ============================================================================
@@ -695,10 +719,44 @@ function loadSubtitleStyle(manifest) {
}
// ============================================================================
// 添加字幕(支持关键词高亮 + 账号字幕风格)
// 添加字幕(支持关键词高亮 + 账号字幕风格 + 分句切分
// ============================================================================
async function addSubtitles(draftUrl, items, timeline, style = {}) {
/**
* 按标点符号切分文本为短句(去除所有标点符号)
*/
function splitTextIntoSentences(text) {
const sentenceEnders = /[。!?;]/
const clauseEnders = /[]/
const sentences = []
let current = ''
let chars = text.split('')
for (let i = 0; i < chars.length; i++) {
const char = chars[i]
current += char
if (sentenceEnders.test(char)) {
// 切分并去掉所有标点
sentences.push(current.trim().replace(/[。!?;,:、]/g, ''))
current = ''
} else if (clauseEnders.test(char) && current.length > 8) {
// 切分并去掉所有标点
sentences.push(current.trim().replace(/[。!?;,:、]/g, ''))
current = ''
}
}
// 处理剩余文本
if (current.trim()) {
sentences.push(current.trim().replace(/[。!?;,:、]/g, ''))
}
return sentences
}
async function addSubtitles(draftUrl, items, timeline, style = {}, split = false) {
const captions = []
// 从账号配置读取动画参数
@@ -713,25 +771,68 @@ async function addSubtitles(draftUrl, items, timeline, style = {}) {
if (!text) continue
const tl = timeline[i]
const keyword = ''
const keywordColor = style.highlightColor || style.color || '#FFFFFF'
const cap = {
start: tl.start,
end: tl.end,
text,
keyword,
keyword_color: keyword ? keywordColor : '',
keyword_font_size: 18,
if (split) {
// 分句模式:切分长文本
const sentences = splitTextIntoSentences(text)
if (sentences.length === 0) continue
const totalDuration = tl.end - tl.start
// 按字数权重分配时间(改进版)
const totalChars = sentences.reduce((sum, s) => sum + s.length, 0)
let currentTime = tl.start
sentences.forEach((sentence, idx) => {
// 按字数比例计算时长
const charRatio = sentence.length / totalChars
let duration = Math.round(totalDuration * charRatio)
// 最后一句使用剩余全部时间(避免精度误差)
if (idx === sentences.length - 1) {
duration = tl.end - currentTime
}
// 最小1秒避免太短
duration = Math.max(duration, 1000000) // 1秒 = 1000000微秒
const cap = {
start: currentTime,
end: currentTime + duration,
text: sentence,
keyword: '',
keyword_color: '',
}
if (inAnimation) cap.in_animation = inAnimation
if (outAnimation) cap.out_animation = outAnimation
if (inAnimDuration) cap.in_animation_duration = inAnimDuration
if (outAnimDuration) cap.out_animation_duration = outAnimDuration
captions.push(cap)
currentTime += duration
})
} else {
// 原始模式:一句字幕
const keyword = ''
const keywordColor = style.highlightColor || style.color || '#FFFFFF'
const cap = {
start: tl.start,
end: tl.end,
text,
keyword,
keyword_color: keyword ? keywordColor : '',
keyword_font_size: 18,
}
if (inAnimation) cap.in_animation = inAnimation
if (outAnimation) cap.out_animation = outAnimation
if (inAnimDuration) cap.in_animation_duration = inAnimDuration
if (outAnimDuration) cap.out_animation_duration = outAnimDuration
captions.push(cap)
}
// 动画参数(每条字幕都带)
if (inAnimation) cap.in_animation = inAnimation
if (outAnimation) cap.out_animation = outAnimation
if (inAnimDuration) cap.in_animation_duration = inAnimDuration
if (outAnimDuration) cap.out_animation_duration = outAnimDuration
captions.push(cap)
}
if (captions.length === 0) {
@@ -766,7 +867,7 @@ async function addSubtitles(draftUrl, items, timeline, style = {}) {
style_text: 0,
})
console.log(` 已添加 ${captions.length} 条字幕 (字体: ${style.font || '默认'}, 动画: ${inAnimation || '无'}${outAnimation || '无'})`)
console.log(` 已添加 ${captions.length} 条字幕${split ? ' (分句模式)' : ''} (字体: ${style.font || '默认'}, 动画: ${inAnimation || '无'}${outAnimation || '无'})`)
}
// ============================================================================
@@ -835,6 +936,7 @@ async function main() {
console.log(' --duration 4 默认每段时长/秒无TTS时的fallback默认 4')
console.log(' --voiceover true|false 是否添加TTS配音轨道默认 true')
console.log(' --subtitles true|false 是否添加字幕(默认 true')
console.log(' --split-captions true|false 分句字幕模式(默认 false长句按标点切分')
console.log(' --bgm <url> 背景音乐 URL')
console.log(' --effects "名称1,名称2" 特效名称(逗号分隔)')
console.log(' --filter "名称:强度" 滤镜(强度 0-100')

View File

@@ -162,7 +162,7 @@ async function generateMJ(item, idx, dir, imagesDir, ratio, refs, manifestPath)
? result.files.map((f, ci) =>
renameGeneratedFile(
path.relative(dir, f).replace(/\\/g, '/'),
dir, idx, item.narration || item.shotDesc, `cand${ci + 1}`
dir, idx, item.script || item.shotDesc, `cand${ci + 1}`
)
)
: null
@@ -217,7 +217,7 @@ async function generateLastFrame(item, idx, manifest, dir, imagesDir, model, rat
if (files.length > 0) {
item.lastFrame = renameGeneratedFile(
path.relative(dir, files[0]).replace(/\\/g, '/'),
dir, idx, item.narration || item.shotDesc, 'last'
dir, idx, item.script || item.shotDesc, 'last'
)
item.status = 'done'
log('images', `[${idx}] lastFrame 完成: ${item.lastFrame}`)

View File

@@ -0,0 +1,158 @@
#!/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 }

View File

@@ -13,9 +13,9 @@
* lib/cmd-*.js ← 子命令实现
*
* 用法:
* node pipeline.js run --manifest ./output/batch/manifest.json --account military --resume
* node pipeline.js run --manifest ./output/batch/manifest.json --phase upload,videos
* node pipeline.js status --manifest ./output/batch/manifest.json
* node scripts/pipeline.js run --manifest ./output/batch/manifest.json --account military --resume
* node scripts/pipeline.js run --manifest ./output/batch/manifest.json --phase upload,videos
* node scripts/pipeline.js status --manifest ./output/batch/manifest.json
*/
const { loadManifest, saveManifest, loadAccountConfig, log } = require('./lib/pipeline-utils')