【媒体处理】基于FFmpeg的AI短剧视频合成流水线架构设计与实践
一、项目概述
本文深入剖析一个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
}
}
音色配置流程:
-
从对白中提取角色名称
-
查询数据库中的角色配置
-
获取角色绑定的voice_id
-
未找到时使用默认音色
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参数解析:
|
参数 |
作用 |
说明 |
|---|---|---|
|
|
视频编码 |
H.264编码 |
|
|
编码速度 |
平衡速度和质量 |
|
|
质量控制 |
恒定质量模式 |
|
|
音频编码 |
AAC编码 |
|
|
时长控制 |
以最短流为准 |
|
|
无音频 |
当无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参数解析:
|
参数 |
作用 |
说明 |
|---|---|---|
|
|
输入格式 |
concat demuxer |
|
|
路径安全 |
允许绝对路径 |
|
|
时间戳 |
重新生成PTS |
|
|
采样率 |
统一音频采样率 |
|
|
快速启动 |
将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
}
网格分割流程:
-
读取原图尺寸信息
-
计算每个单元格的宽高
-
按行列遍历,使用Sharp的
extract方法裁剪 -
输出为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 |
|
下载到临时目录 |
|
绝对路径 |
|
直接使用 |
|
相对路径 |
|
拼接DATA_ROOT |
|
存储路径 |
|
拼接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短剧平台的视频后处理流水线,核心设计包括:
-
三层合成架构:原始视频→单镜头合成→多镜头拼接
-
智能对白处理:自动识别说话人、过滤非对白内容
-
动态语速调整:根据视频时长智能计算TTS语速
-
FFmpeg集成:视频编码、音频混合、字幕烧录一体化
-
网格分割:使用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
火山引擎视频云技术社区,是面向 AI 音视频开发者的技术交流平台。这里汇聚源自抖音、豆包等亿级 DAU 产品的 RTC、直播、点播、AI 媒体处理、音视频互动技术,提供接入指南、最佳实践、性能调优、场景案例、Demo 代码、开源项目、白皮书和 API 文档。社区汇聚官方工程师与一线开发者,为 AI 视频通话、数字人、AI 视频处理等应用的开发与落地提供技术支持。
更多推荐
所有评论(0)