feat(video-pipeline): 将 narration 字段统一重命名为 script
字段重命名涉及所有文档、示例代码、提示词模板以及 slug 生成逻辑,确保管线各阶段(分镜、图生提示词、TTS、成片)一致使用 script 字段承载口播文案。 同时增强 `capcut_assemble.js` 的配音与字幕模块: - 配音支持本地路径模式(不上传 OSS),新增 `--split-captions` 参数启用分句字幕 - 分句字幕按标点切分长文本为短句,基于字数权重动态分配显示时长 - 新增 `update-captions-standalone.js` 独立工具,可在草稿已生成后单独更新字幕
This commit is contained in:
@@ -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)')
|
||||
|
||||
Reference in New Issue
Block a user