一、项目概述

本文深入剖析一个AI短剧创作平台的视频后处理流水线设计。该流水线基于FFmpeg和Sharp等工具,实现了从原始AI生成视频到最终成片的全流程自动化处理,包括:

  • 单镜头合成:原始视频 + TTS语音 + 烧录字幕

  • 多镜头拼接:将所有分镜合成后的一集完整视频

  • 图片网格分割:将AI生成的九宫格图片切割为独立画面

核心处理能力:

模块

输入

输出

核心技术

镜头合成

视频 + 对白文本

合成视频(含配音+字幕)

FFmpeg + TTS

多镜拼接

多个合成镜头

完整单集视频

FFmpeg concat

网格分割

九宫格图片

N张独立画面

Sharp


二、整体架构设计

2.1 视频后处理流水线

┌─────────────────────────────────────────────────────────────────┐
│                     视频后处理流水线                              │
├─────────────────────────────────────────────────────────────────┤
│                                                               │
│  AI生成视频 ──┐                                               │
│               ├──→ 镜头合成 (Compose) ──→ 多镜拼接 (Merge)    │
│  TTS音频 ─────┤                                               │
│               │        │                      │               │
│  对白文本 ────┘        │                      │               │
│                        ▼                      ▼               │
│               ┌────────────────┐    ┌────────────────┐       │
│               │  视频+音频合成  │    │  FFmpeg concat │       │
│               │  + 字幕烧录     │    │  统一编码输出   │       │
│               └────────────────┘    └────────────────┘       │
│                                                               │
│  九宫格图片 ──→ 网格分割 (Grid Split) ──→ 独立画面             │
│                                                               │
└─────────────────────────────────────────────────────────────────┘

2.2 文件流转流程

本地临时文件 ──→ FFmpeg处理 ──→ 输出文件 ──→ 腾讯云COS ──→ 清理本地
     ↑                                                │
     │                                                │
     └────────────── 更新数据库记录 ←──────────────────┘

三、单镜头合成架构

3.1 合成流程设计

┌─────────────────────────────────────────────────────────────┐
│                    单镜头合成流程                             │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  Step 1: 解析对白文本                                       │
│    └── 提取说话人、对白内容、可忽略标记                       │
│                                                             │
│  Step 2: 生成TTS音频                                        │
│    └── 查找角色音色配置                                     │
│    └── 计算语速(基于视频时长和文字长度)                     │
│    └── 调用TTS API生成音频                                  │
│                                                             │
│  Step 3: 生成SRT字幕文件                                    │
│    └── 基于视频时长生成时间轴                               │
│                                                             │
│  Step 4: FFmpeg合成                                         │
│    └── 视频 + 音频 + 字幕烧录                               │
│                                                             │
│  Step 5: 上传COS + 更新数据库                               │
│                                                             │
└─────────────────────────────────────────────────────────────┘

3.2 对白文本解析

function parseDialogueForTTS(dialogue?: string | null) {
  const raw = dialogue?.trim() || ''
  if (!raw) return { speaker: '', pureText: '', ignorable: true }
  
  const speakerMatch = raw.match(/^(.+?)[::]/)
  const speaker = speakerMatch 
    ? speakerMatch[1].replace(/[((].+?[))]/g, '').trim() 
    : ''
  
  const pureText = raw.replace(/^.+?[::]\s*/, '')
    .replace(/[((].+?[))]/g, '').trim()
  
  const ignorable = 
    (!!speaker && IGNORE_TTS_SPEAKERS.test(speaker)) || 
    !pureText || 
    IGNORE_TTS_TEXT.test(pureText)
  
  return { speaker, pureText, ignorable }
}

const IGNORE_TTS_SPEAKERS = /^(环境音|环境声|音效|效果音|sfx|bgm|背景音|背景音乐|ambient)$/i
const IGNORE_TTS_TEXT = /^(无|无对白|无台词|无旁白|none|null|n\/a|环境音|音效|bgm|ambient)$/i

设计亮点:

  • 自动识别"环境音"、"音效"等非对白内容

  • 提取说话人信息用于音色匹配

  • 支持中文冒号(:)和英文冒号(:)两种格式

3.3 智能语速计算

const duration = sb.duration || 10
const charCount = pureDialogue.length
const normalSeconds = charCount / 4.5  // 中文平均4.5字/秒
const speed = Math.min(1.5, Math.max(0.6, normalSeconds / duration))

语速计算逻辑:

  • 基准语速:中文平均4.5字/秒

  • 根据视频时长自动调整

  • 语速范围限制在0.6-1.5倍

3.4 角色音色匹配

if (parsedDialogue.speaker) {
  const charName = parsedDialogue.speaker
  if (ep) {
    const chars = await db.select().from(schema.characters)
      .where(eq(schema.characters.drama_id, ep.drama_id))
    const found = chars.find(c => c.name === charName)
    if (found?.voice_style) voiceId = found.voice_style
  }
}

音色配置流程:

  1. 从对白中提取角色名称

  2. 查询数据库中的角色配置

  3. 获取角色绑定的voice_id

  4. 未找到时使用默认音色alloy

3.5 FFmpeg合成实现

await new Promise<void>((resolve, reject) => {
  let cmd = ffmpeg(videoPath)

  if (audioPath) {
    cmd = cmd.input(audioPath)
  }

  const filters: string[] = []

  if (subtitlePath && supportsSubtitleFilter()) {
    const escapedPath = subtitlePath
      .replace(/\\/g, '/')
      .replace(/:/g, '\\:')
      .replace(/'/g, "\\'")
    const forceStyle = 'FontSize=20\\,PrimaryColour=&HFFFFFF&\\,OutlineColour=&H000000&\\,Outline=2'
    filters.push(`subtitles=filename='${escapedPath}':force_style='${forceStyle}'`)
  }

  if (filters.length > 0) {
    cmd = cmd.videoFilter(filters)
  }

  const outputOptions = ['-c:v', 'libx264', '-preset', 'fast', '-crf', '23', '-s', resolutionStr]

  if (audioPath) {
    outputOptions.push('-map', '0:v', '-map', '1:a', '-c:a', 'aac', '-shortest')
  } else {
    outputOptions.push('-an')
  }

  cmd.outputOptions(outputOptions)
    .output(outputPath)
    .on('end', () => resolve())
    .on('error', (err) => reject(err))
    .run()
})

FFmpeg参数解析:

参数

作用

说明

-c:v libx264

视频编码

H.264编码

-preset fast

编码速度

平衡速度和质量

-crf 23

质量控制

恒定质量模式

-c:a aac

音频编码

AAC编码

-shortest

时长控制

以最短流为准

-an

无音频

当无TTS时移除音轨


四、多镜头拼接架构

4.1 拼接策略选择

export async function mergeEpisodeVideos(
  episodeId: number, 
  dramaId: number, 
  useRawVideos: boolean = false
): Promise<number> {
  const storyboards = await db.select().from(schema.storyboards)
    .where(eq(schema.storyboards.episode_id, episodeId))
    .orderBy(schema.storyboards.storyboard_number)

  let videos: string[] = []
  
  if (useRawVideos) {
    const validStoryboards = storyboards.filter(sb => !!sb.video_url)
    videos = validStoryboards.map(sb => sb.video_url).filter(Boolean)
  } else {
    const composedStoryboards = storyboards.filter(sb => !!sb.composed_video_url)
    videos = composedStoryboards.map(sb => sb.composed_video_url).filter(Boolean)
  }
}

两种拼接模式:

  • 合成模式:使用经过TTS+字幕处理的视频

  • 原始模式:使用AI生成的原始视频

4.2 FFmpeg Concat实现

async function doMerge(mergeId: number, episodeId: number, videos: string[]) {
  const listDir = path.join(STORAGE_ROOT, 'temp')
  const listPath = path.join(listDir, `${uuid()}.txt`)

  const localVideoPaths: string[] = []
  for (const v of videos) {
    const localPath = await toLocalPath(v)
    localVideoPaths.push(localPath)
  }

  const listContent = localVideoPaths
    .map(v => `file '${v.replace(/'/g, "'\\''")}'`)
    .join('\n')
  fs.writeFileSync(listPath, listContent, 'utf-8')

  await new Promise<void>((resolve, reject) => {
    ffmpeg()
      .input(listPath)
      .inputOptions(['-f', 'concat', '-safe', '0'])
      .outputOptions([
        '-fflags', '+genpts',
        '-c:v', 'libx264',
        '-preset', 'medium',
        '-crf', '23',
        '-c:a', 'aac',
        '-ar', '48000',
        '-b:a', '192k',
        '-movflags', '+faststart',
      ])
      .output(outputPath)
      .on('end', () => resolve())
      .on('error', (err) => reject(err))
      .run()
  })
}

Concat参数解析:

参数

作用

说明

-f concat

输入格式

concat demuxer

-safe 0

路径安全

允许绝对路径

-fflags +genpts

时间戳

重新生成PTS

-ar 48000

采样率

统一音频采样率

-movflags +faststart

快速启动

将moov atom移到文件头部


五、图片网格分割

5.1 Sharp图片处理

export async function splitGridImage(
  imagePath: string,
  rows: number,
  cols: number,
): Promise<SplitResult[]> {
  const absPath = imagePath.startsWith('/')
    ? imagePath
    : getAbsolutePath(imagePath)

  const image = sharp(absPath)
  const meta = await image.metadata()
  if (!meta.width || !meta.height) throw new Error('Cannot read image dimensions')

  const cellW = Math.floor(meta.width / cols)
  const cellH = Math.floor(meta.height / rows)

  const results: SplitResult[] = []
  const ts = Date.now()

  for (let r = 0; r < rows; r++) {
    for (let c = 0; c < cols; c++) {
      const index = r * cols + c
      const fileName = `cell_${ts}_${index}.png`
      const outPath = path.join(outDir, fileName)

      await sharp(absPath)
        .extract({ left: c * cellW, top: r * cellH, width: cellW, height: cellH })
        .toFile(outPath)

      results.push({
        index,
        local_path: `static/grid-cells/${fileName}`,
      })
    }
  }

  return results
}

网格分割流程:

  1. 读取原图尺寸信息

  2. 计算每个单元格的宽高

  3. 按行列遍历,使用Sharp的extract方法裁剪

  4. 输出为PNG格式


六、文件路径处理

6.1 多路径格式兼容

async function toLocalPath(videoPath: string): Promise<string> {
  // HTTP/HTTPS远程文件
  if (videoPath.startsWith('http://') || videoPath.startsWith('https://')) {
    const tempDir = path.join(STORAGE_ROOT, 'temp')
    fs.mkdirSync(tempDir, { recursive: true })
    const tempFilename = `${uuid()}.mp4`
    const tempPath = path.join(tempDir, tempFilename)
    
    const resp = await fetch(videoPath)
    if (!resp.ok) throw new Error(`Failed to download video: ${resp.status}`)
    const buffer = Buffer.from(await resp.arrayBuffer())
    fs.writeFileSync(tempPath, buffer)
    
    return tempPath
  }
  
  // 绝对路径
  if (path.isAbsolute(videoPath)) return videoPath
  
  // 相对路径
  if (videoPath.startsWith('static/')) return path.join(DATA_ROOT, videoPath)
  return path.join(STORAGE_ROOT, videoPath)
}

支持的路径格式:

格式

示例

处理方式

HTTP URL

https://cdn.example.com/video.mp4

下载到临时目录

绝对路径

/data/static/videos/xxx.mp4

直接使用

相对路径

static/videos/xxx.mp4

拼接DATA_ROOT

存储路径

videos/xxx.mp4

拼接STORAGE_ROOT


七、字幕功能检测

7.1 FFmpeg功能检测

function supportsSubtitleFilter(): boolean {
  if (subtitleFilterSupport != null) return subtitleFilterSupport
  try {
    const output = execFileSync('ffmpeg', ['-hide_banner', '-filters'], { encoding: 'utf8' })
    subtitleFilterSupport = /\bsubtitles\b/.test(output)
  } catch {
    subtitleFilterSupport = false
  }
  return subtitleFilterSupport
}

设计亮点:

  • 使用单例缓存,避免重复检测

  • 运行时检测FFmpeg功能支持

  • 优雅降级:不支持字幕烧录时跳过


八、TTS语音合成

8.1 MiniMax TTS适配器

export class MiniMaxTTSAdapter implements TTSProviderAdapter {
  buildGenerateRequest(config: any, params: TTSParams) {
    const body: any = {
      model: params.model || 'speech-2.8-hd',
      text: params.text,
      stream: false,
      voice_setting: {
        voice_id: params.voice,
        speed: params.speed ?? 1,
        vol: 1,
        pitch: 0,
        emotion: params.emotion || 'happy',
      },
      audio_setting: {
        sample_rate: 32000,
        bitrate: 128000,
        format: 'mp3',
        channel: 1,
      },
    }
    return { url, method: 'POST', headers, body }
  }

  parseResponse(result: any): TTSResult {
    return {
      audioHex: data.audio,
      audioLength: data.extra_info?.audio_length || 0,
      sampleRate: data.extra_info?.audio_sample_rate || 32000,
      bitrate: data.extra_info?.bitrate || 128000,
      format: data.extra_info?.audio_format || 'mp3',
      channel: data.extra_info?.audio_channel || 1,
    }
  }
}

8.2 音频处理流程

export async function generateTTS(params: TTSParams): Promise<string> {
  const config = await getAudioConfigById(params.configId)
  const adapter = getTTSAdapter(config.provider)

  const { url, method, headers, body } = adapter.buildGenerateRequest(config, params)

  const resp = await fetch(url, { method, headers, body: JSON.stringify(body) })
  const result = await resp.json()
  const parsed = adapter.parseResponse(result)

  const buffer = Buffer.from(parsed.audioHex, 'hex')

  const audioDir = path.join(STORAGE_ROOT, 'audio')
  fs.mkdirSync(audioDir, { recursive: true })
  const filename = `${uuid()}.${parsed.format || 'mp3'}`
  const filePath = path.join(audioDir, filename)
  fs.writeFileSync(filePath, buffer)

  return `static/audio/${filename}`
}

音频处理特点:

  • 支持Hex编码的音频响应

  • 支持自定义语速、情感、音色

  • 自动生成唯一文件名避免冲突


九、架构优势总结

特性

实现方式

技术价值

智能对白处理

正则解析 + 可忽略标记

自动过滤非对白内容

动态语速

基于视频时长计算

确保对白与画面同步

音色匹配

角色配置绑定

每个角色有专属音色

字幕烧录

FFmpeg subtitle filter

运行时检测,优雅降级

路径兼容

多格式支持 + 自动下载

支持本地和远程文件

资源清理

处理后删除临时文件

节省磁盘空间


十、总结

本文深入剖析了AI短剧平台的视频后处理流水线,核心设计包括:

  1. 三层合成架构:原始视频→单镜头合成→多镜头拼接

  2. 智能对白处理:自动识别说话人、过滤非对白内容

  3. 动态语速调整:根据视频时长智能计算TTS语速

  4. FFmpeg集成:视频编码、音频混合、字幕烧录一体化

  5. 网格分割:使用Sharp实现高效的图片切割

该流水线在保证视频质量的同时,实现了从AI生成到最终成品的全自动化处理,是AI内容生成平台的典型后处理实践方案。


核心文件参考:

  • 镜头合成:/backend/src/services/ffmpeg-compose.ts

  • 多镜拼接:/backend/src/services/ffmpeg-merge.ts

  • 网格分割:/backend/src/services/grid-split.ts

  • TTS服务:/backend/src/services/tts-generation.ts

  • MiniMax TTS:/backend/src/services/adapters/minimax-tts.ts

Logo

火山引擎视频云技术社区,是面向 AI 音视频开发者的技术交流平台。这里汇聚源自抖音、豆包等亿级 DAU 产品的 RTC、直播、点播、AI 媒体处理、音视频互动技术,提供接入指南、最佳实践、性能调优、场景案例、Demo 代码、开源项目、白皮书和 API 文档。社区汇聚官方工程师与一线开发者,为 AI 视频通话、数字人、AI 视频处理等应用的开发与落地提供技术支持。

更多推荐