本文收录专栏「一个人用AI做工具矩阵」,关于智播坊的技术深潜系列。

做口播视频平台最难的不是前端界面,而是后端那条流水线——用户点"生成"后,文案怎么变成带字幕的口播视频?
今天拆解智播坊的核心链路,从TTS语音合成到FFmpeg视频合成,全链路代码级讲解。

一、整体架构:9步流水线

用户点击生成后,后端会依次执行9个步骤:

文案输入
  ↓
Step 1: 下载形象图片(+ 抠图处理)
  ↓
Step 2: TTS语音合成(耗时最长,15%-45%)
  ↓
Step 3: 获取音频精确时长
  ↓
Step 4: 生成字幕时间轴(从TTS分段数据对齐)
  ↓
Step 5: 生成SRT字幕文件
  ↓
Step 6: FFmpeg视频合成(形象+背景+音频+字幕烧录)
  ↓
Step 7: 生成WebVTT字幕(前端预览用)
  ↓
Step 8: 上传COS + 提取缩略图
  ↓
Step 9: 清理临时文件

这个流水线不是一开始就规划成9步的。最早只有"TTS → FFmpeg"两步,后来字幕不对、时长不准、临时文件撑爆磁盘……一步步踩坑加到9步。


二、TTS:分句合成 + FFmpeg拼接 + 时长修正

1. 分句策略:按标点分,不按字数分

火山方舟TTS API对单次输入的文本长度有限制,直接把整段文案丢进去容易截断或超时。所以我的策略是先分句,再逐句合成,最后拼接:

// 按中文标点分句,保留句子完整性
function splitText(text: string, maxLength: number = 200): string[] {
  const punctuationRegex = /[。?!;,.?!;,\n]/
  const sentences: string[] = []
  
  const parts = text.split(punctuationRegex)
  
  parts.forEach(part => {
    const trimmed = part.trim()
    if (trimmed.length < 1) return
    
    if (trimmed.length <= maxLength) {
      sentences.push(trimmed)  // 正常长度直接用
    } else {
      // 超长句按字符数拆分
      for (let i = 0; i < trimmed.length; i += maxLength) {
        const chunk = trimmed.substring(i, i + maxLength).trim()
        if (chunk.length >= 1) sentences.push(chunk)
      }
    }
  })
  return sentences
}

为什么按标点分而不是按固定字数分?因为TTS是按句断句的,如果在一个词中间切开,合成出来的语音会在奇怪的地方停顿。


2. 流式TTS API调用

火山方舟TTS v3 API返回的是流式响应——不是一次性给你完整音频,而是一行行JSON,每行包含一段base64编码的音频数据:

// 流式响应处理
response.data.on('data', (chunk: Buffer) => {
  data += chunk.toString()
})

response.data.on('end', () => {
  const lines = data.split('\n').filter(line => line.trim())
  let audioChunks: Buffer[] = []

  lines.forEach(line => {
    const json = JSON.parse(line)
    if (json.code === 0 && json.data) {
      // 解码base64音频
      audioChunks.push(Buffer.from(json.data, 'base64'))
    } else if (json.code === 20000000 && json.data === null) {
      // 结束标记,不含音频数据
      console.log('收到结束标记')
    }
  })
  
  const finalAudio = Buffer.concat(audioChunks)
})

踩过一个大坑:结束标记的code是20000000,不是0。之前我只判断了code===0的行,结果最后一段数据丢失,合并出来的音频末尾会"嘎然而止"。
还有一个细节——base64数据完整性验证:

const combinedBase64 = base64Strings.join('')
const endsWithPadding = combinedBase64.endsWith('=') || combinedBase64.endsWith('==')
if (!endsWithPadding && combinedBase64.length > 0) {
  console.warn('base64 数据可能不完整,建议检查是否丢失了结束标记前的数据')
}

base64编码后的数据应该以=或==结尾(padding),如果没有,说明数据可能不完整。这个检查帮我排查过好几次流式传输中断的问题。


3. 克隆音色的处理

智播坊支持声音克隆,克隆音色的API调用跟标准TTS不同——Resource ID要用seed-icl-2.0,speaker直接传S_开头的音色ID:

// 克隆音色检测
const isClonedVoice = voiceType && (voiceType.startsWith('clone_') || voiceType.startsWith('S_'))

if (isClonedVoice) {
  // 使用声音复刻专用接口
  const resourceId = 'seed-icl-2.0'
  const requestBody = {
    user: { uid: `zhibofang_${Date.now()}` },
    req_params: {
      text: text,
      speaker: voiceType,  // 直接传 S_xxx 格式
      audio_params: { format: "mp3", sample_rate: 24000 },
      additions: JSON.stringify({ explicit_language: "zh" })  // 关键:语言标识
    }
  }
}

explicit_language: "zh" 这行差点漏掉。没有它,克隆音色有时候会说中英文混合的奇怪内容。


4. FFmpeg concat合并分句音频

逐句合成后,需要把所有片段拼接成一段完整音频。我用FFmpeg的concat demuxer,直接复制不重新编码,保持音频质量:

async function concatenateAudioWithFFmpeg(
  segmentFiles: string[],
  silenceDuration: number,  // 分句间的静音间隔
  outputPath: string
): Promise<Buffer> {
  let filesToConcat = [...segmentFiles]
  
  // 为每个分句间插入0.3秒静音
  if (silenceDuration > 0 && segmentFiles.length > 1) {
    for (let i = 0; i < segmentFiles.length - 1; i++) {
      const silenceFile = path.join(tempDir, `silence_${i}.mp3`)
      // FFmpeg生成静音MP3
      await execAsync(
        `ffmpeg -y -f lavfi -i "anullsrc=r=24000:cl=mono" -t ${silenceDuration} -codec:a libmp3lame -qscale:a 2 "${silenceFile}"`
      )
      filesToConcat.splice(i * 2 + 1, 0, silenceFile)
    }
  }
  
  // 创建concat列表文件
  const concatListContent = filesToConcat.map(f => `file '${f}'`).join('\n')
  fs.writeFileSync(concatListPath, concatListContent)
  
  // -c copy 直接复制,不重新编码
  await execAsync(`ffmpeg -y -f concat -safe 0 -i "${concatListPath}" -c copy "${outputPath}"`)
}

分句间加0.3秒静音,听起来才自然。不加的话两句话紧挨着,听感像机关枪。


5. 时长修正:字幕同步的关键

TTS合成的每段音频,时长是按字节数估算的(audioData.length / 8000),不准。合并后用ffprobe拿到精确时长,再按比例修正每段字幕的时间轴:

// ffprobe获取实际时长
const actualDuration = await getAudioDuration(localPath)

// 按比例修正每段字幕时间
const durationRatio = actualDuration / estimatedTotalDuration
let adjustedStartTime = 0
for (const seg of segmentDurations) {
  seg.start = adjustedStartTime
  seg.duration = seg.duration * durationRatio  // 等比缩放
  adjustedStartTime += seg.duration + SILENCE_DURATION
}

不做这步修正的话,越往后字幕和语音越不同步——因为每段的估算误差会累积。


三、FFmpeg视频合成:滤镜链是核心

这是整条流水线最复杂的部分。一条FFmpeg命令要同时处理:形象图叠加、背景渲染、字幕烧录、音频混合。

1. 滤镜链构建:三种背景模式

// 构建滤镜链
const filterParts: string[] = []

// 背景层(3种情况)
if (effectiveBackgroundType === 'image' && effectiveBackgroundValue) {
  // 有背景图:铺满 + 叠加形象
  filterParts.push(
    `[0:v]scale=${W}:${H}:force_original_aspect_ratio=decrease[scaled];` +
    `[1:v]scale=${W}:${H}:force_original_aspect_ratio=increase,crop=${W}:${H}[bg_img];` +
    `[bg_img][scaled]overlay=(W-w)/2:(H-h)/2[body_base]`
  )
} else if (effectiveBackgroundType === 'color') {
  // 纯色背景
  filterParts.push(
    `color=c=${bgColor}:size=${W}x${H}:r=25[bg];` +
    `[0:v]scale=${W}:${H}:force_original_aspect_ratio=decrease[scaled];` +
    `[bg][scaled]overlay=(W-w)/2:(H-h)/2[body_base]`
  )
} else {
  // 无背景:用形象主色调
  filterParts.push(
    `color=c=${dominantColor}:size=${W}x${H}:r=25[bg];` +
    `[0:v]scale=${W}:${H}:force_original_aspect_ratio=decrease[scaled];` +
    `[bg][scaled]overlay=(W-w)/2:(H-h)/2[body_base]`
  )
}

// 字幕烧录(SRT + subtitles滤镜)
if (srtPath) {
  const primaryColor = toAssColor(subtitleFontColor)  // #FFFFFF → &H00FFFFFF(BGR顺序!)
  filterParts.push(
    `[body_base]subtitles='${escapedSrtPath}':force_style='FontSize=${fontSize},PrimaryColour=${primaryColor},Outline=2,Alignment=2,MarginV=20'[outv]`
  )
}
ASS字幕颜色是BGR,不是RGB。 #FF0000(红色)要转成&H000000FF:
typescript
function toAssColor(hexColor: string): string {
  const hex = hexColor.replace('#', '')
  const r = hex.substring(0, 2)
  const g = hex.substring(2, 4)
  const b = hex.substring(4, 6)
  return '&H00' + b + g + r  // RGB → BGR
}

这个坑我踩了整整半天——字幕颜色死活不对,最后翻ASS格式规范才发现是BGR顺序。


2. spawn替代exec:防止内存爆掉

最开始我用child_process.exec执行FFmpeg命令,结果长视频(3分钟+)合成时直接报maxBuffer exceeded。因为exec把stdout/stderr全部缓存到内存里,FFmpeg的进度输出量巨大。
改用spawn,流式消费stderr,不再有内存问题:

const proc = spawn(FFMPEG_PATH, spawnArgs)

// 消费stderr防止进程阻塞
let errorOutput = ''
proc.stderr?.on('data', (chunk) => {
  errorOutput += chunk.toString()
})

proc.on('close', (code) => {
  if (code === 0) resolve()
  else reject(new Error(`ffmpeg exited with code ${code}`))
})

3. 中文字体:跨平台兼容

字幕烧录要指定字体,不同操作系统的中文字体路径完全不同。我的方案是运行时动态检测:

function getChineseFontPath(): string {
  // 优先用项目自带的字体包(最可靠)
  const projectFont = path.join(process.cwd(), 'src', 'assets', 'fonts', 'HiraginoSansGB.ttc')
  if (fs.existsSync(projectFont)) return projectFont
  
  const platform = os.platform()
  if (platform === 'darwin') {
    // macOS: 冬青黑体 > 华文黑体
    const macFonts = [
      '/System/Library/Fonts/Hiragino Sans GB.ttc',
      '/System/Library/Fonts/STHeiti Light.ttc',
    ]
    for (const font of macFonts) {
      if (fs.existsSync(font)) return font
    }
  } else if (platform === 'win32') {
    return 'C:\\Windows\\Fonts\\msyh.ttc'  // 微软雅黑
  } else {
    // Linux: 文泉驿 > Noto > Droid
    const linuxFonts = [
      '/usr/share/fonts/truetype/wqy/wqy-microhei.ttc',
      '/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc',
      '/usr/share/fonts/truetype/droid/DroidSansFallbackFull.ttf',
    ]
    for (const font of linuxFonts) {
      if (fs.existsSync(font)) return font
    }
  }
}

最稳的方案是在项目里自带一份字体文件,不依赖系统。开发阶段我为了省空间没这么做,结果部署到Linux服务器上字幕全是方块——系统没装中文字体 😅 后来老老实实把字体文件打进项目了。


4. CSS渐变背景转FFmpeg

前端背景选择器用CSS渐变(linear-gradient(135deg, #667eea, #764ba2)),但FFmpeg不认识CSS语法。我的处理方式是把CSS渐变转成渐变图片,再当背景图用:

async function convertCssGradientToFFmpegInput(backgroundValue: string) {
  const colors = parseGradientColors(backgroundValue)  // 提取 #hex 颜色
  const color1 = colors[0].replace('#', '')
  const color2 = colors[colors.length - 1].replace('#', '')
  
  // FFmpeg gradients滤镜生成渐变图片
  const gradientCmd = `${FFMPEG_PATH} -y -f lavfi -i "gradients=size=1280x720:c0=0x${color1}:c1=0x${color2}:duration=1:rate=25" -frames:v 1 -update 1 "${tempFile}"`
  await execWithTimeout(gradientCmd, 30000)
  
  return { type: 'image', value: tempFile, tempFile }
}

一个小细节:渐变图片生成后要加入临时文件保护列表,合成完成前不能被清理。之前有次并发生成两个视频,第一个完了清掉了临时目录,第二个视频的背景图就没了。


四、进度推送与任务队列

1. 伪进度 + WebSocket

视频生成要几十秒到几分钟,用户不可能干等。我用WebSocket实时推送进度,但有个问题——FFmpeg合成时无法获取精确进度(除非解析stderr的time字段,太脆了)。
我的解决方案:伪进度 + 真实终点。

// 每个步骤分配一个进度区间
const STEP_RANGES = {
  'download_avatar': { start: 0, end: 5 },     // 下载形象
  'process_avatar': { start: 5, end: 15 },     // 处理形象
  'tts': { start: 15, end: 45 },               // TTS(最耗时)
  'video': { start: 45, end: 80 },              // 视频合成
  'subtitle': { start: 80, end: 95 },           // 字幕
  'upload': { start: 95, end: 100 },            // 上传
}

// 启动伪进度:每秒线性递增,到达区间终点就停
export function startStepProgress(videoId: number, stepName: string) {
  const range = STEP_RANGES[stepName]
  const incrementPerTick = (range.end - range.start) / estimatedDuration
  
  const timer = setInterval(() => {
    currentPercent += incrementPerTick
    if (currentPercent >= range.end) {
      currentPercent = range.end  // 停在终点,不超过
      clearInterval(timer)
    }
    pushProgress('', videoId, stepName, Math.round(currentPercent))
  }, 1000)
}

// 步骤完成时,用实际进度覆盖伪进度
export function stopStepProgress(videoId: number, actualPercent?: number) {
  clearInterval(timer)
  pushProgress('', videoId, stepName, actualPercent || currentPercent)
}

伪进度的关键原则:只往上涨,到区间终点就停,等真实完成信号再跳到准确值。用户看到进度条在动就不会焦虑,步骤完成后也不会倒退。


2. 串行任务队列

视频生成是CPU密集型任务,同时跑多个FFmpeg进程会把服务器搞崩。用了最简单的串行队列:

let videoQueue: Array<{ videoId: number; params: VideoPipelineParams }> = []
let isProcessing = false

async function processQueue() {
  if (isProcessing || videoQueue.length === 0) return
  isProcessing = true
  const task = videoQueue.shift()!
  
  try {
    const result = await executeVideoPipeline(task.params)
    updateVideoRecord(task.videoId, {
      status: 'completed',
      url: result.videoUrl,
      duration: result.duration
    })
  } catch (error) {
    updateVideoRecord(task.videoId, { status: 'failed', error: error.message })
  } finally {
    isProcessing = false
    processQueue()  // 处理下一个
  }
}

个人项目不需要消息队列,一个数组 + 一个锁就行。


3. 形象处理链路

形象图片的处理链路比较长,因为用到了火山引擎的抠图API,而抠图API需要CDN URL作为输入:

远程图片URL → curl下载到本地 → 上传COS获取CDN URL → 调用抠图API → 下载抠图结果到本地

抠图成功后hasNoBg = true,FFmpeg合成时就可以用透明背景的PNG叠在任何背景上。如果抠图失败,就用原图(有白色背景),至少不会白屏。


五、前端:两步创建流程

1. 第一步:输入文案

支持单条添加和批量导入。批量导入时自动识别格式,移除序号前缀、过滤空行:

function handleImport() {
  const lines = importText.value
    .split(/[\n,;]/)                         // 按换行/逗号/分号分割
    .map(line => line.trim())
    .filter(line => line && !/^\d+[.、))]?\s*$/.test(line))  // 过滤空行和纯序号
    .map(line => line.replace(/^\d+[.、))]\s*/, ''))         // 移除序号前缀
  scripts.value.push(...lines)
}

2. 第二步:选择形象、声音、背景

5种预设背景(CSS渐变 + 纯色),字幕开关 + 字号滑块 + 颜色选择器。背景选项的值直接存CSS渐变字符串,后端收到后再转成FFmpeg能用的渐变图片。


六、踩坑总结

原因

解法

exec爆内存

FFmpeg stderr输出量巨大

改用spawn,流式消费

字幕颜色不对

ASS格式是BGR不是RGB

&H00 + BGR转换函数

Linux服务器字幕方块

没装中文字体

项目自带字体文件

视频末尾音频截断

TTS结束标记code=20000000没处理

解析时识别结束标记

字幕越往后越不同步

分段时长是估算值,误差累积

ffprobe精确时长 + 等比修正

渐变背景被并发清理

临时文件没加保护列表

tempFiles数组追踪


这条流水线从最初的两步(TTS→FFmpeg)迭代到9步,每一步都是被bug逼出来的。FFmpeg的坑尤其多——滤镜链语法、颜色格式、字体路径、内存管理……每个都是一整天的调试。但好消息是,一旦跑通了,这条流水线就非常稳定。


智播坊开源地址:https://gitee.com/zhang-dongtao/zhibofang

在线演示地址:https://zhibofang.zhishujuzhen.com/


有问题欢迎留言,或者直接在Gitee提Issue ⚙️


你们做过视频合成的项目吗? FFmpeg的滤镜链写过吗?有没有遇到更离谱的坑?评论区聊聊,我赌5毛你遇到的字体坑比我还多 👇

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐