一、项目概述
本文深入剖析一个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.tsTTS服务:
/backend/src/services/tts-generation.tsMiniMax TTS:
/backend/src/services/adapters/minimax-tts.ts