用Golang给TA写个在一起365天视频模板,代码比情话还实在
- 体育
- 2026-08-04 14:58:52
- 48
为什么非要用Golang搞这个视频模板?
兄弟,你是不是也刷到过那种“恋爱365天纪念日”的短视频?满屏粉红泡泡,背景音乐一响,照片一张张翻过去,但你想过没有——这些视频背后,其实全是重复劳动,今天这个模板,明天那个模板,换个照片换个名字,本质都一样。
我去年跟女朋友在一起第300天的时候,就想做个专属视频,但用剪映吧,手动拖素材能把我手指头磨出茧子;用Python吧,她又不懂代码,以后想自己改点东西还得求我,最后我拍板:用Go写个命令行工具,一条命令生成完整视频,为啥?因为Go编译出来是个干净的可执行文件,她双击就能跑,不需要装Python环境,不需要配依赖。
这就是我写这个模板的初衷——用工程思维解决浪漫问题,今天把这套东西拆开给你看,顺便聊聊怎么用Golang把“365天”这个数字玩出花来。
先理清楚:这个模板到底要产出什么?
动手写代码之前,你得先搞清楚需求,我当初列了个清单:
- 输入:一对情侣的照片(按日期命名,
2024-01-15.jpg) - 输出:一个MP4视频,每张照片停留3秒,底部滚动显示“第XX天”的文字
- 附加:开头有标题“我们在一起的第365天”,结尾有“未完待续”
- 技术指标:1080p分辨率,30fps,兼容主流手机播放
看着简单吧?但真要实现,坑不少,比如Go标准库不直接支持视频编码,你得借助FFmpeg,这里我推荐用 github.com/u2takey/ffmpeg-go 这个库,封装得挺干净,比直接调命令行参数强多了。
第一步:用Go生成照片序列的“时间轴”
你别想着一口气生成视频,FFmpeg吃的是图片序列+音频流,所以咱们得先把照片按时间顺序排好,生成一个文本清单。
func buildImageList(dir string) ([]string, error) {
files, _ := filepath.Glob(filepath.Join(dir, "*.jpg"))
sort.Strings(files) // 按文件名排序,因为日期在文件名里
return files, nil
}
但这里有个坑:如果照片是 2024-01-02.jpg 和 2024-01-10.jpg,字符串排序没问题,可要是你哪天漏拍了,日期断档了,那就得处理“第X天没有照片”的情况,我当时的策略是:没照片的日子用上一张重复,但打个半透明水印“这天偷懒没拍”,哈哈,真实生活嘛,总有那么几天懒得化妆。
第二步:动态生成文字渲染——用Go的image包画字幕
FFmpeg的drawtext滤镜虽好,但中文字体路径乱七八糟的,在Windows上尤其麻烦,我干脆用Go的 image + image/draw 包,先把文字画到PNG上,再把PNG作为覆盖层叠到照片上。
func drawTextOverlay(imgPath string, day int, outputPath string) {
// 打开原图
img, _ := loadImage(imgPath)
// 创建画布,尺寸一样
canvas := image.NewRGBA(img.Bounds())
// 底部画个半透明黑条
draw.Draw(canvas, canvas.Bounds(), &image.Uniform{C: color.RGBA{0,0,0,180}}, image.Point{}, draw.Over)
// 写文字:"Day 365"
font := loadFont("msyh.ttf") // 微软雅黑,Windows自带
point := ftfont.NewFace(font, &ftfont.Options{Size: 48})
drawText(canvas, fmt.Sprintf("Day %d", day), point, 50, 100)
// 保存为新图
savePNG(outputPath, canvas)
}
这里有个细节得注意:加载字体,Go的 golang.org/x/image/font 包需要你提供字体文件路径,Windows上直接拿 C:\Windows\Fonts\msyh.ttf 就行,但要判断文件存在,Mac上就是 /System/Library/Fonts/PingFang.ttc,你要是怕麻烦,去GitHub搜个开源中文字体放项目里也行,但注意版权——思源黑体是开源可商用的,推荐。
第三步:让FFmpeg把图片序列变成视频
到这一步,你已经有了所有带日期的PNG图片,接下来就是调用FFmpeg合成视频,用 ffmpeg-go 库,代码比命令行还清晰:
func buildVideo(images []string, output string) {
err := ffmpeg.
Input("frames/%06d.png", ffmpeg.KwArgs{"framerate": 1}).
Output(output, ffmpeg.KwArgs{
"c:v": "libx264",
"pix_fmt": "yuv420p",
"vf": "scale=1920:1080",
"r": 30,
}).
OverWriteOutput().
Run()
if err != nil {
log.Fatal("视频生成失败:", err)
}
}
但这里有个大坑:帧率与停留时间的矛盾,如果你把每张图片设为1帧,那播放速度就是1秒1张,想每张停留3秒?两种办法:
- 把帧率设为1/3,但FFmpeg不支持小数帧率
- 更稳妥:每张照片在Go里重复生成3帧,然后总帧率设30,那么每张实际播放就是3秒
我一开始用的第一种,结果视频播放时一顿一顿的,后来改成第二种,顺畅多了,代价是磁盘空间多了点,但咱不在乎那几百MB。
第四步:加上音乐和转场效果——别让视频干巴巴的
光有照片和文字太单调了,我从 freepd.com 下了一首CC0授权的轻音乐,用FFmpeg混流进去:
func addAudio(videoPath, audioPath, outputPath string) {
err := ffmpeg.
Input(videoPath).
Input(audioPath).
Output(outputPath, ffmpeg.KwArgs{
"c:v": "copy",
"c:a": "aac",
"shortest": true,
}).
OverWriteOutput().
Run()
}
转场效果呢?我偷懒了——没用交叉淡化,而是用Go在两张图之间生成中间帧(比如从黑到亮渐入),方法也不难:读取两张图,线性插值像素值,生成10帧过渡帧,效果嘛,有点类似老式幻灯片切换,但胜在代码简单。
进阶玩法:把“365天”变成一个可配置参数
你可能会说:“我只想做100天的视频,难道要改代码?”不用,我写了个 config.json:
{
"totalDays": 365,: "我们的365天",
"subtitle": "从春天到下一个春天",
"fontPath": "fonts/source-han-sans.otf",
"photoDir": "./photos",
"outputDir": "./output",
"musicPath": "./music/romantic.mp3",
"resolution": "1920x1080",
"framePerPhoto": 90
}
主程序读取配置,
type Config struct {
TotalDays int `json:"totalDays"` string `json:"title"`
Subtitle string `json:"subtitle"`
FontPath string `json:"fontPath"`
PhotoDir string `json:"photoDir"`
OutputDir string `json:"outputDir"`
MusicPath string `json:"musicPath"`
Resolution string `json:"resolution"`
FramePerPhoto int `json:"framePerPhoto"`
}
你只要改这个JSON,不管做30天还是1000天,都行。
写完代码,我踩过哪些坑?
- 中文路径:Windows下如果照片文件夹在桌面,路径里有中文,FFmpeg可能乱码,解决方案:全部用相对路径,或转成 分隔的绝对路径,并加上
filepath.ToSlash()。 - 内存溢出:如果你有几千张照片,一次性加载所有图片再处理,内存直接爆炸,我改成流式处理:处理完一张,写入临时文件夹,释放内存。
- 时间戳精度:如果你拍了照片但Exif信息不准,按文件名排序可能乱,我加了
time.Parse解析文件名,如果解析失败就回退到Exif,要是还没,就归入“未分类”文件夹。
实战演示:一条命令生成36个视频碎片
我女朋友有个习惯,喜欢把每个月的纪念视频单独存,我做了个变体:按月份分割,每个月生成一个短视频,最后再合并,用Go的 filepath.Walk 按月分组,然后并发调用 buildVideo——Go的goroutine这时候就派上用场了。
var months map[string][]string
for _, path := range files {
monthKey := path[:7] // 2025-01
months[monthKey] = append(months[monthKey], path)
}
var wg sync.WaitGroup
for k, paths := range months {
wg.Add(1)
go func(key string, ps []string) {
defer wg.Done()
buildVideo(ps, fmt.Sprintf("output/%s.mp4", key))
}(k, paths)
}
wg.Wait()
6核CPU下,36个月份视频并行生成,总共花了不到两分钟,要是单线程,得等十分钟。
把视频变成“可编程”的:加入日期计数动画
最后我想加个细节:在视频右下角实时显示“在一起第X天”的计数,不是死文字,而是每天自动更新的数字动画,这其实是用Go生成一个 counter_frames 目录,里面存了从1到365的PNG数字,然后FFmpeg循环播放这个目录作为覆盖层。
func generateCounter() {
for i := 1; i <= 365; i++ {
canvas := generateDigitImage(i)
savePNG(fmt.Sprintf("counter/%04d.png", i), canvas)
}
}
然后用FFmpeg的 overlay 滤镜把视频和计数器叠加:
err := ffmpeg.
Input("base.mp4").
Input("counter/%04d.png").
FilterComplex("overlay=W-w-20:H-h-20").
Output("final.mp4").
Run()
这个计数器动画是独立于照片的,也就是说,哪怕你某天没拍照,计数器照常走,这就把“365天”变成了一个动态存在感,而不是被动记录。
给代码加点“温度”:错误提示也必须温柔
日常写代码,报错信息都是一脸冷漠,我重写了错误处理,让程序在崩溃时输出:
func gracefulError(err error) {
if err != nil {
fmt.Println("哎,遇到点小麻烦:", err)
fmt.Println("不过别慌,检查一下照片文件名是不是都是 'YYYY-MM-DD.jpg' 这样的格式")
os.Exit(1)
}
}
她第一次跑的时候,照片命名里带了个 _副本 后缀,程序弹出了这个提示,她顺着看了一下文件名,立刻明白怎么改了。有温度的报错,比干巴巴的日志强一万倍。
把EXE打包给不懂代码的她
项目做好后,我交叉编译了Windows版本:
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -o daily-video.exe
然后我把 config.json 和 daily-video.exe 放到一个文件夹里,Zip压缩,发给她,她只需要解压,双击exe,等一分钟,视频就躺在 output 文件夹里了。
这种“零门槛交付”的感觉,特别像你为伴侣精心准备了一份礼物,但礼物的打开方式却毫不费力,她甚至能自己换照片、换音乐,每次生成都有新鲜感——有时候她为了让我惊喜,偷偷往文件夹里塞两张新照片,然后跟我说“帮我生成一下”。
