用Go语言,把365天的恋爱剪成一部电影
- 房产
- 2026-08-13 19:02:16
- 65
写代码和剪视频,本质上是一回事——都是把零散的片段,拼凑成一个完整的故事。
上个月,我女朋友过生日,我提前三个月就开始琢磨送什么,口红?去年送过了,包包?预算不够,直到某天我翻手机相册,看到一年前我们第一次约会的照片——那家街角咖啡店,她点的拿铁还拉歪了花,我突然冒出一个念头:如果把这一年的恋爱日常,做成一个视频会怎样?
说干就干,我第一反应是用PR(Premiere Pro),但转念一想,我是个写Go的,干嘛不用代码干这事?我花了整整两个周末,写了个Go程序,把365天的照片、视频片段、聊天记录截图、甚至微信语音转成的文字,全部拼成了一个“恋爱365天视频剪辑版”。
今天我不是来炫耀浪漫的,我是来分享这个技术方案的,因为在这个过程中,我踩了不少坑,也发现了一些特别有用的Go库,希望对你也有帮助。
为什么选Go而不是Python或JS?
坦白说,最开始我想用Python,因为有个moviepy库好像专门干这个,但我后来放弃了,原因有三个:
- 并发处理:365天,每天至少5张照片,加上视频,就是几千个文件,Python的GIL(全局解释器锁)在并发处理媒体文件这种事上,真的有点捉急,而Go的goroutine,我开个
runtime.NumCPU()个 worker,分分钟把图片压缩、格式转换这些重活给摊平了。 - 部署简单:我最后要把这个程序跑在一台旧笔记本上,Go编译出来就是个单个二进制文件,扔上去就能跑,你要是用Python,还得到处装依赖环境,麻烦。
- 标准库丰富:Go的
image、os、path/filepath这些标准库,对付文件读取和基础图像处理已经够用,再加上第三方库,基本覆盖需求。
整体架构:分四个模块
我的思路是把整个“剪辑”过程拆成四步,对应四个Go包:
| 模块包名 | 职责 | 关键库 |
|---|---|---|
ingest |
扫描目录,读取所有媒体文件,提取时间戳 | path/filepath, os |
process |
统一裁剪尺寸、压缩体积、加滤镜 | github.com/nfnt/resize, image |
segment |
按时间轴切分场景,生成字幕 | 自研 + golang.org/x/text/encoding |
render |
用FFmpeg合成最终MP4 | os/exec 调用ffmpeg |
其实核心就是最后一步——调用FFmpeg,Go本身不直接合成视频,但Go负责指挥FFmpeg干活,就像导演和摄影师的关系。
第一步:用Go处理文件和时间轴
type Moment struct {
Path string
TakenAt time.Time
IsVideo bool
}
这个Moment结构体是核心,我遍历整个“恋爱相册”文件夹,用filepath.Walk递归找出所有.jpg、.png、.mov、.mp4文件,然后用os.Stat拿到文件的修改时间——但这里有个坑:照片的EXIF信息里的拍摄时间往往比文件修改时间更准。
我用了一个小技巧:读取JPEG文件的EXIF信息(用github.com/rwcarlsen/goexif/exif),提取DateTimeOriginal字段,如果解析失败,再去fallback到文件修改时间,这样能保证时间轴排序准确,要不然两年后翻看视频,发现顺序乱了,那可就尴尬了。
排序用sort.SliceStable,按TakenAt升序排,然后每7天作为一个“章节”,生成一个章节标题,第10周:一起炖的汤糊了”。
第二步:裁剪和压缩,我踩的坑
所有手机拍的照片尺寸都不统一,iPhone是4032x3024,安卓各有各的奇葩比例,如果直接喂给FFmpeg,最后出来的视频会忽大忽小,看着难受。
所以我在process包里统一处理:
func resizeTo720p(img image.Image) *image.RGBA {
// 等比缩放,宽超1080就缩到1080,然后居中裁剪成16:9
}
我用了github.com/nfnt/resize库做缩放。但注意,这个库的Resize函数只处理宽和高,不处理旋转,很多手机竖屏拍的照片,EXIF里带了一个Orientation=6的旋转标记,如果你不处理,出来的照片是躺着的。
解决方案:用golang.org/x/image/tiff?不,其实得用github.com/disintegration/imaging这个库,它的AutoOrient函数直接搞定旋转问题,这个坑我花了半天才爬出来,你要用的话直接抄作业。
压缩格式我统一转成JPEG,质量设置quality=85,这样既保证清晰度,又不至于让文件太大,最后在FFmpeg里用-crf 23的H.264编码,整个视频大小控制在200MB以内,发微信原视频毫无压力。
第三步:字幕和背景音乐——用代码生成歌词字幕
这个部分最浪漫,我把一年里我们聊过的有意义的微信对话、或者旅行时的日志,做成滚动字幕,每个字幕对应一个时间段。
我用了srt(SubRip)格式,在Go里生成srt文件非常简单:
1
00:00:01,000 --> 00:00:04,500
第一天,在机场见你,你走丢了。
我存在一个[]Subtitle切片里,最后循环写入.srt文件,字幕样式我没搞太多花哨,白色字体加黑色描边,在FFmpeg用subtitles=love.srt滤镜加载。
背景音乐选了一首她很喜欢的《晴天》,但音乐版权问题你得注意,自己私下看看就行,别上传公开平台,我用的FFmpeg命令是这样的:
ffmpeg -i video.mp4 -i music.mp3 -c:v copy -c:a aac -shortest final.mp4
对,这段其实我没用Go代码,直接shellexec跑了,但Go负责拼接这一段命令行字符串,Go里拼接字符串比Python容易出反斜杠错误,我建议你用fmt.Sprintf:
cmd := fmt.Sprintf("ffmpeg -i %s -i %s -c:v copy -c:a aac -shortest %s", videoPath, musicPath, outputPath)
第四步:运行时的真实吐槽
我程序第一次跑的时候,整整跑了40分钟没结束,原因是处理几千张图片,每张都要解压、缩放、重新编码,而且我一开始没用goroutine,纯串行。
后来我重构了一下,在process包加上并发池:
var wg sync.WaitGroup
sem := make(chan struct{}, 4) // 限制4个并发
for _, m := range moments {
wg.Add(1)
go func(mm Moment) {
defer wg.Done()
sem <- struct{}{}
defer func(){ <-sem }()
processOne(mm)
}(m)
}
wg.Wait()
注意:processOne里要处理好map的并发写问题,最好别共享全局map,而是用chan把结果回传,或者给map加锁,我这里图省事,直接用了带mutex的结构体,但性能一般,如果你文件特别多,建议用管道模式。
给点“血的教训”
- 别用
time.Now()做文件名,我有个目录里图片后缀是.jpeg其实是PNG,导致解码失败,解决方案:读文件头几个字节(magic number)判断真实格式,用http.DetectContentType也行。 - FFmpeg的滤镜语法特别容易转义出错,比如
drawtext滤镜里如果有冒号或引号,你的Go字符串里得疯狂加反斜杠,我干脆放弃在drawtext写复杂字幕,改用SRT文件,清爽多了。 - 内存别爆,一次加载全部图片到
[]image.Image,365天×每天5张=1825张,每张解码后RGBA大概4MB,就是7GB内存,电脑直接卡死。流式处理——读一张,处理一张,写临时文件,再释放,别贪心。
我的“恋爱365天视频剪辑版”已经生成了,时长大概12分钟,前5分钟是照片流,中间是几个小视频片段(我们爬山、在海边奔跑),最后是字幕卡——“谢谢你这一年的容忍,尤其是当我把袜子扔在沙发上”。
你问我用什么心情写这段代码?其实就像谈恋爱一样,你得有耐心去处理那些意外情况,就像Go里那个error返回值,你永远不知道下一张照片会不会在解码时给你个unexpected EOF,但当你处理完所有错误,编译通过,go run跑出第一帧画面时,那种成就感,比她说“好喜欢”还爽。
写代码和爱一个人,都不能太急,把每个if err != nil都当成一次“闹别扭”,处理好,关系就更进一步,祝你的视频也能顺利生成。
