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

@@ -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')