做独立开发这两年,我一直在想一个问题:一个人到底能做到什么程度?
上周我给出了自己的答案——我用 DeepSeek 定义需求 + CodeBuddy 辅助编码,一个人从零搞了一个 AI 口播视频生成平台,取名智播坊。输入文案,选形象和声音,一键生成带字幕的口播视频。
现在它开源了:https://gitee.com/zhang-dongtao/zhibofang
在线体验:https://zhibofang.zhishujuzhen.com/
演示视频:输入文案,选形象和声音,30秒生成一条口播视频
为什么做这个?
我之前做表单工具的时候,想拍几个产品演示视频发抖音。结果发现:
• 真人出镜?社恐,而且拍摄+剪辑要一整天
• 用数字人平台?月费几百起步,生成一条视频还要排队
• 自己用 FFmpeg 拼?技术上可行,但每次手动操作太麻烦
我就想:能不能把"写文案→配音→合成视频"这一整套流程自动化?于是智播坊就诞生了。
核心流程:一条全自动的视频流水线
整个视频生成过程拆成了 9 步,用户只需要输入文案和选择形象,剩下的全自动:
输入文案 → TTS语音合成 → 获取音频时长 → 生成字幕时间轴
→ SRT字幕文件 → FFmpeg视频合成 → WebVTT字幕 → 上传COS → 清理临时文件
核心代码都在video-pipeline.service.ts里,大概 1600 行,我挑几个关键的环节讲讲。
第一个坎:怎么把文案变成带时间轴的语音?
这步是整条流水线的起点,也是最容易出问题的地方。短文案还好,直接调 TTS 就完事。但长文案就麻烦了——火山方舟的 TTS 单次有字数限制,超过就生成失败。
我的方案是:长文案自动拆分,逐段生成,再用 FFmpeg 拼接。
// TTS 结果里带 segments,每段都有 start 和 duration export interface TTSSegment { text: string start: number // 秒 duration: number } // 合并时在分句间插入静音间隔,听起来更自然 async function concatenateAudioWithFFmpeg( segmentFiles: string[], silenceDuration: number, outputPath: string ): Promise<Buffer> { // 用 FFmpeg concat demuxer 合并,-c copy 直接复制不重新编码 const concatCmd = `ffmpeg -y -f concat -safe 0 -i "${concatListPath}" -c copy "${outputPath}"` await execAsync(concatCmd) }segments 数据是关键——它不仅用于拼接音频,后面生成字幕时间轴也全靠它。一次 TTS 调用,音频和字幕数据同时拿到,不用再做什么语音识别对齐。
第二个坎:FFmpeg 合成视频的滤镜链
视频合成的核心逻辑:形象图 + 背景 + 音频 + 字幕 = 最终视频。听起来简单,FFmpeg 的 filter_complex 写起来能让人崩溃。
async function generateVideoOneCommand( avatarPath: string, audioPath: string, subtitles: SubtitleLine[], audioDuration: number, hasNoBg: boolean, // 形象是否已抠图 config: { ... }, outputPath: string, srtPath?: string ): Promise<void> { // 构建滤镜链 const filterParts: string[] = [] if (effectiveBackgroundType === 'image') { // 背景图铺满 + 形象居中叠加 filterParts.push( `[0:v]scale=${VIDEO_WIDTH}:${VIDEO_HEIGHT}:force_original_aspect_ratio=decrease[scaled];` + `[1:v]scale=${VIDEO_WIDTH}:${VIDEO_HEIGHT}:force_original_aspect_ratio=increase,crop=${VIDEO_WIDTH}:${VIDEO_HEIGHT}[bg_img];` + `[bg_img][scaled]overlay=(W-w)/2:(H-h)/2[body_base]` ) } else { // 纯色/渐变背景 + 形象居中 filterParts.push( `color=c=${bgColor}:size=${VIDEO_WIDTH}x${VIDEO_HEIGHT}:r=25[bg];` + `[0:v]scale=${VIDEO_WIDTH}:${VIDEO_HEIGHT}:force_original_aspect_ratio=decrease[scaled];` + `[bg][scaled]overlay=(W-w)/2:(H-h)/2[body_base]` ) } // 字幕烧录用 FFmpeg subtitles 滤镜,比 drawtext 靠谱 if (srtPath) { filterParts.push( `[body_base]subtitles='${escapedSrtPath}':force_style='FontSize=${config.subtitleFontSize},PrimaryColour=${primaryColor},Outline=2,Alignment=2,MarginV=20'[outv]` ) } }踩坑提醒:字幕方案我前后换过三次。
1. 第一次用drawtext滤镜——中文字符转义是个噩梦,冒号、引号、反斜杠全是坑
2. 第二次用ass格式——比 drawtext 好点,但时间轴对齐容易偏移
3. 最后用了subtitles滤镜 + SRT 文件——最稳的方案,SRT 格式简单不易出错,force_style 参数还能控制字号颜色描边
第三个坎:中文字体跨平台
这个坑我踩了整整一天。本地开发用 macOS 没问题,部署到 Linux 服务器上字幕全是方框。
原因很简单:FFmpeg 渲染字幕需要指定字体文件路径,不同系统的字体路径完全不一样。
function getChineseFontPath(): string { const platform = os.platform() // 优先用项目内的字体包(最可靠) const projectFont = path.join(process.cwd(), 'src', 'assets', 'fonts', 'HiraginoSansGB.ttc') if (fs.existsSync(projectFont)) return projectFont if (platform === 'darwin') { // macOS: Hiragino Sans GB / STHeiti 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: 各种发行版路径都不一样... const linuxFonts = [ '/usr/share/fonts/truetype/wqy/wqy-microhei.ttc', '/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc', '/usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc', ] for (const font of linuxFonts) { if (fs.existsSync(font)) return font } } }最终方案:把字体文件打包进项目里,运行时优先读项目内字体,找不到再按系统路径找。部署再也没出过问题。
第四个坎:形象抠图 + 透明背景叠加
用户上传的形象图背景五花八门,直接放到视频里很违和。我的处理流程:
上传形象 → 上传COS → 调用抠图API → 下载抠图结果 → 透明背景PNG叠加到视频
// getAvatarPath 的核心流程 async function getAvatarPath(avatarId: number, userId: number) { // 1. 下载远程图片到本地 // 2. 上传到 COS,获取 CDN URL // 3. 调用抠图 API(传入 CDN URL) const bgResult = await removeBackground(cosCdnUrl) // 4. 下载抠图结果 if (bgResult.success && bgResult.imageUrl) { const noBgLocalPath = path.join(tempDir, `avatar_nobg_${avatarId}.png`) await execWithTimeout(`curl -s -o "${noBgLocalPath}" "${bgResult.imageUrl}"`) return { path: noBgLocalPath, hasNoBg: true } } }抠图成功后,FFmpeg 合成时用 overlay 滤镜把透明背景的 PNG 叠到背景上,效果自然很多。
还有个加分项:声音克隆
除了 5 种预设音色,智播坊还接入了火山引擎的声音克隆。用户上传一段音频样本,就能用克隆的声音生成口播视频。
// 克隆音色检测:clone_ 或 S_ 开头的音色 ID const isClonedVoice = voiceType && (voiceType.startsWith('clone_') || voiceType.startsWith('S_')) if (isClonedVoice) { // 用声音复刻专用 TTS 接口 const resourceId = 'seed-icl-2.0' // 不同于普通 TTS 的 seed-tts-2.0 // speaker 直接用原始音色 ID }普通音色用seed-tts-2.0,克隆音色用seed-icl-2.0,两个 resource ID 不能混用,这个坑也踩了好久。
技术栈一览
层 | 技术 |
前端 | Vue 3 + Vite + TypeScript + TailwindCSS + Element Plus |
后端 | Express.js + TypeScript + SQLite + JWT |
AI 服务 | 火山方舟(TTS + 文案生成)+ Seedream 图片生成 |
视频合成 | FFmpeg(图片 + TTS + 字幕 → 口播视频) |
存储 | 腾讯云 COS |
实时通信 | WebSocket(视频生成进度推送) |
整个项目前后端加起来不到 1600 行核心代码,一个人完全能 hold 住。
为什么要开源?
说实话,我本来是想靠卖源码赚钱的。但做了两个月发现,个人开发者卖源码,获客成本比开发成本还高。
与其让代码在硬盘里吃灰,不如开源出来:
• 让更多人能低成本搭建自己的口播视频工具
• 收集社区反馈,把产品做得更好
• 用内容和技术实力吸引真正需要商业授权的客户
开源版包含核心视频生成功能,MIT 协议随便用。商业版多了套餐订阅、在线支付、订单管理这些运营模块,适合想直接商业化部署的团队。
怎么跑起来?
// bash # 克隆 git clone https://gitee.com/zhang-dongtao/zhibofang.git cd zhibofang # 安装依赖 cd frontend && npm install cd ../backend && npm install # 配置环境变量 cp .env.example .env # 填入你的火山方舟 API Key、腾讯云 COS 配置等 # 初始化数据库 npm run db:init # 启动 # 后端 npm run dev # 前端 cd ../frontend && npm run dev详细的部署文档和一键部署脚本都在仓库 README 里,支持 Nginx + SSL + PM2 全自动配置。
写在最后
做这个项目最大的感受:AI 时代,一个人的生产力真的可以被无限放大。
我用 DeepSeek 定义需求和梳理逻辑,CodeBuddy 帮我写代码,我主要做架构决策和踩坑调试。整个项目从 v1.0 到 v2.5,核心开发时间加起来也就两周左右。
当然不是所有事都能靠 AI,比如 FFmpeg 滤镜链的调试、跨平台字体的坑、TTS 流式响应的解析——这些还是得自己硬啃。但 AI 帮我省掉了大量重复性编码时间,让我能把精力放在真正需要思考的地方。
智播坊 Gitee 仓库:https://gitee.com/zhang-dongtao/zhibofang
如果觉得有用,给个 Star ⭐ 就行,这是对独立开发者最大的鼓励。
你们做口播视频目前用的什么方案?我是 FFmpeg + TTS 的路子,有人试过直接用数字人 API 吗?效果和成本对比怎么样?评论区聊聊 👇