一、项目概述本文深入剖析一个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未找到时使用默认音色alloy3.5 FFmpeg合成实现await new Promisevoid((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 FontSize20\\,PrimaryColourHFFFFFF\\,OutlineColourH000000\\,Outline2 filters.push(subtitlesfilename${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 ): Promisenumber { 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 Promisevoid((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, ): PromiseSplitResult[] { 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): Promisestring { // 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 URLhttps://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): Promisestring { 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