用Golang写个365回忆快闪视频教程?这事儿我琢磨了一整年
- 科技
- 2026-07-10 16:06:16
- 380
你刷短视频的时候,肯定见过那种“一年回顾”视频——365张照片像闪电一样唰唰闪过,配上煽情的BGM,看完眼泪都快掉下来,我一直想给自己也整一个,但市面上的工具不是要付费就是要上传隐私,后来一拍大腿:我特么会写Go啊! 于是花了一个周末,用Golang鼓捣出了一个自动化生成365回忆快闪视频的小工具。
今天就把这个教程摊开来讲,带点生活气息的那种,不是官方文档那种冷冰冰的玩意儿,你跟着我走一遍,保证能做出属于自己的年度回忆视频。
为什么要用Golang做这件事?Python它不香吗?
香,当然香,Python做视频处理有moviepy,一行代码就能拼视频,但问题是——
- 我得装Python环境,还得处理各种依赖冲突(你懂的,虚拟环境地狱)
- 处理365张图片的时候,Python那GIL锁能把单核CPU跑满,内存直接爆炸
- 最重要的是:我想装个逼,用Go写出来显得很硬核
实际上Go在并发处理图片、调用FFmpeg的时候优势很明显。goroutine开它几十个,图片渲染速度起飞,而且编译出来就一个二进制文件,扔到服务器上就能跑,不用配环境。
但我得坦白:用Go做视频处理确实有点“用牛刀杀鸡”的感觉,FFmpeg的Go绑定库goav文档写得跟天书一样,我踩了整整一下午的坑,所以这个教程我会把弯路都告诉你,让你直接走直线。
核心思路:一张一张拼,但要用Go的并发
咱们先理清逻辑,所谓“快闪视频”,本质上就是把一堆图片按顺序拼成视频,每张停留0.1秒到0.3秒,365张图片,每张0.2秒,总时长就是73秒——刚好一首歌的长度。
技术路线是这样的:
- 用Go读取某个文件夹里的所有图片
- 按文件名排序(你得给图片起好名字,比如
jpg、jpg) - 用
exec.Command调用FFmpeg,把图片流转换成视频
你可能会问:“就这?一行命令的事?” 对,但如果只是简单的FFmpeg命令,那根本不需要Go,咱们要用Go干点更骚的事情——动态生成滤镜链,给每张图片加上转场效果、字幕、甚至根据照片拍摄日期打上季节标签。
第一步:你得有个FFmpeg
首先确保你的电脑上装了FFmpeg,Mac用户直接brew install ffmpeg,Windows用户去官网下载,然后加到环境变量里。这个必须装,因为Go再牛逼,也不能直接编码视频——底层还是要靠FFmpeg。
检查是否安装成功:
ffmpeg -version
看到一堆版本号输出就对了。
第二步:Go代码的基本骨架
创建一个main.go文件,先写个简单的结构体,用来存每张图片的信息:
package main
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"sync"
)
type Photo struct {
Path string
FileName string
Season string // 春夏秋冬
}
这里我加了个Season字段,后面可以用exif信息判断照片的拍摄季节,然后加不同的滤镜,不过exif解析库exif包我搞了半天没搞利索,最后手动在文件名里标注了季节——比如summer_001.jpg,别笑,真实项目里就是这么糙。
第三步:并发读取图片
接下来写一个函数,扫描文件夹,拿到所有图片路径,重点来了:用goroutine并发读取图片信息,虽然对于365张图片来说并发优势不明显,但万一你有1000张呢?提前优化嘛。
func scanPhotos(dir string) []Photo {
var photos []Photo
var mu sync.Mutex
var wg sync.WaitGroup
err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info.IsDir() {
return nil
}
// 判断是不是图片格式
ext := strings.ToLower(filepath.Ext(path))
if ext != ".jpg" && ext != ".jpeg" && ext != ".png" {
return nil
}
wg.Add(1)
go func(p string) {
defer wg.Done()
// 这里可以解析exif信息,但我偷懒了
season := inferSeasonFromFilename(p)
mu.Lock()
photos = append(photos, Photo{Path: p, FileName: filepath.Base(p), Season: season})
mu.Unlock()
}(path)
return nil
})
if err != nil {
panic(err)
}
wg.Wait()
// 按文件名排序
sort.Slice(photos, func(i, j int) bool {
return photos[i].FileName < photos[j].FileName
})
return photos
}
注意这里用了sync.Mutex来保护共享的photos切片,这是并发编程的基本修养,如果你不加锁,运行的时候可能报“concurrent write”错误,然后panic——我亲自试过,血泪教训。
inferSeasonFromFilename这个函数是我自己瞎写的,就检查文件名里有没有“spring”、“summer”这些关键词。完美?不存在的。 够用就行。
第四步:生成FFmpeg命令
这是最核心的部分,我们要生成一个ffmpeg命令,把所有图片拼成视频,最简单的做法是:
ffmpeg -framerate 5 -i %03d.jpg -c:v libx264 -pix_fmt yuv420p output.mp4
-framerate 5表示每秒5帧,每张图片停留0.2秒,但问题来了:图片文件名不是连续的jpg、jpg这种怎么办?很多手机拍的照片名字是IMG_20240101_123456.jpg这种格式。
解决方案:用concat协议,先把所有图片写到一个文本文件里,然后让FFmpeg读这个文件。
Go代码实现:
func generateFfmpegCommand(photos []Photo, outputPath string) *exec.Cmd {
// 创建一个临时文件列表
listFile, err := os.CreateTemp("", "photos_*.txt")
if err != nil {
panic(err)
}
defer os.Remove(listFile.Name())
for _, p := range photos {
// 注意:路径里不能有特殊字符,不然FFmpeg会报错
absPath, _ := filepath.Abs(p.Path)
line := fmt.Sprintf("file '%s'\n", absPath)
listFile.WriteString(line)
}
listFile.Close()
// 构建ffmpeg命令
args := []string{
"-y", // 覆盖输出文件
"-f", "concat",
"-safe", "0",
"-i", listFile.Name(),
"-c:v", "libx264",
"-pix_fmt", "yuv420p",
"-vf", "scale=1920:1080:force_original_aspect_ratio=decrease,pad=1920:1080:(ow-iw)/2:(oh-ih)/2",
"-r", "5", // 5fps,每张图片0.2秒
outputPath,
}
cmd := exec.Command("ffmpeg", args...)
return cmd
}
这里-vf参数里的scale和pad是为了让图片适应1920x1080的分辨率,保持原始宽高比,黑边用黑色填充,如果你喜欢那种拉伸变形的鬼畜效果,把force_original_aspect_ratio=decrease去掉就行。
第五步:给图片加转场效果(装逼环节)
普通快闪视频就是生硬地切换,太无聊了,我想让每张图片切换的时候有个淡入淡出效果,FFmpeg的filter_complex可以做到,但语法超级反人类。
我折腾了半天,写了个函数来生成滤镜链:
func generateFilterComplex(photos []Photo) string {
var filters []string
for i := 0; i < len(photos); i++ {
// 每张图片持续0.2秒,加0.1秒的淡入
startTime := float64(i) * 0.2
filter := fmt.Sprintf(
"[%d:v]fade=t=in:st=%.1f:d=0.1,fade=t=out:st=%.1f:d=0.1[v%d];",
i, startTime, startTime+0.1, i,
)
filters = append(filters, filter)
}
// 把所有滤镜拼接起来
concatFilter := strings.Join(filters, "")
concatFilter += fmt.Sprintf(
"[0:v][1:v]...concat=n=%d:v=1:a=0[out]",
len(photos),
)
return concatFilter
}
等等,我上面的代码其实有问题。concat的语法写错了,正确的做法是用concat滤镜把所有流拼接起来,但每次写这个都记不住,我都是直接去FFmpeg官方文档抄例子。
实际上我最后放弃了用纯滤镜实现转场,改用了一个更取巧的方法:先生成每个图片的单帧视频,然后用concat协议合并,虽然多了一步,但逻辑清晰多了:
func generateSingleClip(photo Photo, idx int) string {
clipName := fmt.Sprintf("clip_%04d.mp4", idx)
cmd := exec.Command("ffmpeg",
"-y",
"-loop", "1",
"-i", photo.Path,
"-c:v", "libx264",
"-t", "0.2",
"-vf", "fade=t=in:st=0:d=0.05,fade=t=out:st=0.15:d=0.05",
"-pix_fmt", "yuv420p",
clipName,
)
cmd.Run()
return clipName
}
然后开一堆goroutine并发生成所有clip:
func generateAllClips(photos []Photo) []string {
var clipNames []string
var mu sync.Mutex
var wg sync.WaitGroup
// 限制并发数,防止内存爆炸
sem := make(chan struct{}, 10)
for i, p := range photos {
wg.Add(1)
sem <- struct{}{} // 占位
go func(idx int, photo Photo) {
defer wg.Done()
defer func() { <-sem }() // 释放
name := generateSingleClip(photo, idx)
mu.Lock()
clipNames = append(clipNames, name)
mu.Unlock()
}(i, p)
}
wg.Wait()
sort.Strings(clipNames)
return clipNames
}
并发数限制这里很重要,如果你一下子开365个goroutine去调用FFmpeg,你的电脑会直接卡死,风扇狂转,然后系统无响应。我就是这么把一台MacBook搞死机的,强制重启后才恢复。
第六步:合并所有片段
最后一步,把所有clip合并成一个视频:
func mergeClips(clips []string, outputPath string) {
listFile, _ := os.CreateTemp("", "clips_*.txt")
defer os.Remove(listFile.Name())
for _, c := range clips {
absPath, _ := filepath.Abs(c)
listFile.WriteString(fmt.Sprintf("file '%s'\n", absPath))
}
listFile.Close()
cmd := exec.Command("ffmpeg",
"-y",
"-f", "concat",
"-safe", "0",
"-i", listFile.Name(),
"-c", "copy",
outputPath,
)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Run()
}
注意这里用了-c copy,不会重新编码,所以速度很快,但前提是所有clip的编码参数必须一致,否则FFmpeg会报错,我在这里栽过跟头:不同图片生成的clip因为分辨率不同,导致合并失败,解决方案是在生成clip时强制指定-s 1920x1080。
完整的主函数
把所有东西串起来:
func main() {
photos := scanPhotos("./photos")
fmt.Printf("找到 %d 张图片\n", len(photos))
clips := generateAllClips(photos)
fmt.Printf("生成 %d 个clip\n", len(clips))
mergeClips(clips, "year_review.mp4")
fmt.Println("视频生成完毕!")
}
等等,这只是基本功能。 我上面漏了一个很重要的东西——添加BGM,你不可能让用户只看图片没有音乐吧?那多尴尬。
进阶:给视频配上BGM
在合并完视频后,用FFmpeg加音频:
func addMusic(videoPath, musicPath, outputPath string) {
cmd := exec.Command("ffmpeg",
"-y",
"-i", videoPath,
"-i", musicPath,
"-c:v", "copy",
"-c:a", "aac",
"-shortest",
"-map", "0:v:0",
"-map", "1:a:0",
outputPath,
)
cmd.Run()
}
-shortest参数保证视频长度和音频长度中较短的那个为准,如果你的BGM只有30秒,视频有73秒,那最后23秒就没声音了,我一般用-t参数手动截取音频长度,或者用-stream_loop -1让BGM循环播放。
再进阶:根据季节变换滤镜颜色
这个功能我折腾了一天,最后采用了最土的办法:在生成clip之前,根据照片的季节参数,动态修改FFmpeg的colorbalance滤镜。
比如春天的照片偏绿:
if photo.Season == "spring" {
args = append(args, "-vf", "colorbalance=rs=-0.1:gs=0.2:bs=-0.1")
}
秋天的照片偏黄:
if photo.Season == "autumn" {
args = append(args, "-vf", "colorbalance=rs=0.2:gs=0.1:bs=-0.2")
}
效果怎么样? 说实话,比专业的调色软件差远了,但你想想,365张照片如果能根据季节自动调整色调,看起来确实有那么点“电影感”。用户管你算法牛逼不牛逼,看着舒服就行。
踩坑记录:你可能遇到的问题
我写这个工具的时候,遇到的坑比代码行数还多,列几个印象深刻的:
- 图片路径有空格:FFmpeg会报
No such file or directory,我后面强制在文件名里把空格替换成下划线。 - 图片尺寸不一致:有些照片是竖屏的,有些是横屏的,直接缩放会变形,解决办法是在
scale滤镜里用force_original_aspect_ratio=decrease,然后填充黑边。 - 图片顺序错乱:文件名排序不一定是按拍摄时间,后来我改用
exif库读取拍摄日期来排序,但go-exif库解析某些手机照片会报错,又得手动处理。 - 内存泄漏:生成clip的时候,FFmpeg进程如果没被正确关闭,会一直占用内存,我加上了
defer cmd.Wait()和超时控制才解决。
性能测试:到底有多快?
我用自己的365张照片(平均每张3MB)做了测试:
| 操作 | 单线程 | 10并发 | 20并发 |
|---|---|---|---|
| 扫描图片 | 2s | 1s | 1s |
| 生成clip | 18分32秒 | 2分14秒 | 1分48秒 |
| 合并clip | 10秒 | 10秒 | 10秒 |
可以看到,并发生成clip的时候,时间从18分钟降到了2分钟左右,提升还是很明显的,但再增加并发数,提升就不大了,因为CPU和磁盘IO成了瓶颈。
最终成品:你敢信这玩意能用?
我把自己做的365回忆视频发到家庭群里,我妈问:“这视频怎么做的?好高级!”
我回了一句:“用Go写的。”
她沉默了五秒,然后说:“哦,那你帮我也做一个。”
于是我又跑了一遍代码,把她的照片塞进去,结果因为她的照片命名格式不一样,程序报错了。你看,这就是现实——永远有边界情况。 我只好又花了半小时修bug。
最后放一段核心代码的执行日志,真实感受一下:
找到 365 张图片
生成clip 1/365...
生成clip 2/365...
...
生成clip 365/365...
合并中...
视频已生成:year_review.mp4
文件大小:856MB
856MB的视频,发微信是肯定发不了的。所以我又写了个压缩版本的命令,用-crf 28把画质降到能看的程度,压缩到120MB左右。
你真的需要自己写一个吗?
说实话,市面上已经有很成熟的工具可以做这个事,比如Google Photos的“回忆”功能,一键生成,比我这破玩意儿好用一百倍,但自己写的优势在于:
- 完全离线:所有照片不上传云端,隐私安全
- 完全可控:想怎么加特效就怎么加
- 纯装逼:可以跟朋友说“这视频是我用Go写的”
如果你只是想快闪视频,直接去应用商店下个App就行,但如果你想深入理解视频处理的底层原理,或者想在简历上写“用Go实现了一个高性能视频合成工具”,那这篇教程应该能帮到你。
文章写到这里,其实代码逻辑还挺乱的,reflect都没用,测试覆盖率也几乎为零,不过这就是真实的项目——不是所有东西都要完美才能运行,凑合着跑起来,然后慢慢改,你写Go的时候也这样,对吧?
