feat(video-pipeline): 将 narration 字段统一重命名为 script
字段重命名涉及所有文档、示例代码、提示词模板以及 slug 生成逻辑,确保管线各阶段(分镜、图生提示词、TTS、成片)一致使用 script 字段承载口播文案。 同时增强 `capcut_assemble.js` 的配音与字幕模块: - 配音支持本地路径模式(不上传 OSS),新增 `--split-captions` 参数启用分句字幕 - 分句字幕按标点切分长文本为短句,基于字数权重动态分配显示时长 - 新增 `update-captions-standalone.js` 独立工具,可在草稿已生成后单独更新字幕
This commit is contained in:
@@ -165,7 +165,7 @@ node .claude/skills/video-from-script/scripts/gemini-image-generator.js edit \
|
||||
# 批量带参考图(pipeline init + run)
|
||||
node .claude/skills/video-from-script/scripts/pipeline.js init \
|
||||
--account forbidden-emperor --mode single \
|
||||
--items '[{"shotDesc":"...","narration":"...","duration":5,"imagePrompt":"...","directorRef":"tarantino"}]'
|
||||
--items '[{"shotDesc":"...","script":"...","duration":5,"imagePrompt":"...","directorRef":"tarantino"}]'
|
||||
node .claude/skills/video-from-script/scripts/pipeline.js run \
|
||||
--manifest ./output/forbidden-emperor_XXXXXXXX_001/manifest.json \
|
||||
--phase images
|
||||
@@ -328,7 +328,7 @@ const r = await mjGen('prompt', { outputDir: './out', aspectRatio: '9:16' })
|
||||
|
||||
## 文件命名规则
|
||||
|
||||
Pipeline 生成的文件统一命名,slug 来自 manifest item 的 `narration` 或 `shotDesc` 字段(slugify: 保留中文和字母数字,最多 20 字符,其余变 `_`):
|
||||
Pipeline 生成的文件统一命名,slug 来自 manifest item 的 `script` 或 `shotDesc` 字段(slugify: 保留中文和字母数字,最多 20 字符,其余变 `_`):
|
||||
|
||||
| 模式 | 文件名 | 示例 |
|
||||
|------|--------|------|
|
||||
|
||||
@@ -56,7 +56,7 @@ output/{name}_{YYYYMMDD}_{NNN}/
|
||||
└── audio/ # seg_001.mp3
|
||||
```
|
||||
|
||||
命名:图片 `scene_01_悬浮.jpeg` → 视频 `scene_01_悬浮.mp4`(slug 从 narration/shotDesc 派生,支持中文)
|
||||
命名:图片 `scene_01_悬浮.jpeg` → 视频 `scene_01_悬浮.mp4`(slug 从 script/shotDesc 派生,支持中文)
|
||||
|
||||
## manifest.json
|
||||
|
||||
@@ -71,7 +71,7 @@ node scripts/gemini-image-generator.js edit "prompt" -i ./references/ref1.png -o
|
||||
# Pipeline 批量生图(推荐)
|
||||
node scripts/pipeline.js init \
|
||||
--account {account} --mode single \
|
||||
--items '[{"shotDesc":"...","narration":"...","duration":5,"imagePrompt":"...","directorRef":"tarantino"}]'
|
||||
--items '[{"shotDesc":"...","script":"...","duration":5,"imagePrompt":"...","directorRef":"tarantino"}]'
|
||||
node scripts/pipeline.js run \
|
||||
--manifest ./output/{account}_XXXXXXXX_001/manifest.json \
|
||||
--phase images
|
||||
|
||||
@@ -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>
|
||||
|
||||
# 初始化 manifest(Step 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}.jpeg(slug 从 narration/shotDesc 派生,首尾帧加 _last 后缀)
|
||||
├── images/ # scene_{NN}_{slug}.jpeg(slug 从 script/shotDesc 派生,首尾帧加 _last 后缀)
|
||||
├── videos/ # scene_{NN}_{slug}.mp4(与图片对应)
|
||||
└── urls.json # OSS 公网 URL 映射
|
||||
```
|
||||
|
||||
@@ -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 |
|
||||
|
||||
|
||||
@@ -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/MJ),Step 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` 内部行为
|
||||
|
||||
@@ -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)')
|
||||
|
||||
@@ -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}`)
|
||||
|
||||
@@ -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 }
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user