365张照片变视频一天,我用Go语言写了个工具,把一整年的回忆压缩成60秒
- 科技
- 2026-06-30 16:30:47
- 305
说实话,最开始我根本没想到这事能成,那天整理手机相册,翻到去年拍的照片——365张,一天一张,突然就想,要是能把这些照片串成一个视频,那得多酷啊?一年就这样浓缩在一分钟里,从春天到冬天,从早到晚,然后我搜了一圈,要么收费,要么水印巨大,要么操作复杂得跟核反应堆似的,算了,自己动手吧,用Go语言干。
为什么是Go?又不是视频处理的传统语言
很多人第一反应:视频处理不是该用Python吗?OpenCV、MoviePy不香吗?确实香,但问题在于,批量处理365张照片,Go的并发优势就出来了,Go处理文件I/O和并发任务真的很舒服,而且编译出来就是个二进制文件,扔服务器上或者直接本地跑都行,不用配环境。
我用的核心库是 ffmpeg-go,它其实是FFmpeg的Go语言绑定,FFmpeg本身就是视频处理的事实标准,没有任何商业库比它更权威,Go作为胶水语言,完美地把FFmpeg的强大能力暴露出来,同时写起来比直接写FFmpeg命令优雅一百倍。
第一步:照片统一尺寸(避免视频“跳帧”)
照片尺寸千奇百怪:竖屏的、横屏的、正方形的,直接扔进视频里,画面会忽大忽小,看起来像PPT翻页,我用Go批量处理了一下:
import "github.com/u2takey/ffmpeg-go"
func resizeImage(input, output string, width, height int) {
err := ffmpeg_go.Input(input).
Filter("scale", ffmpeg_go.Args{fmt.Sprintf("%d:%d", width, height)}).
Output(output).OverWriteOutput().ErrorToStdOut().Run()
}
这里用了 scale 滤镜,强制把照片统一成 1920x1080,这样会拉伸变形,更好的做法是 先裁剪再缩放,用 crop 滤镜配合 scale,我选择了居中裁剪,保证人脸不被切掉太多。
第二步:用时间戳排序(而不是文件名)
相册里的照片文件名可能是 IMG_20240101.jpg 这种,但更可能是 照片(1).jpg 这种乱七八糟的命名,Go的 time 包从EXIF信息里读出拍摄时间:
type Photo struct {
Path string
Timestamp time.Time
}
func getEXIFDate(path string) time.Time {
// 用 github.com/rwcarlsen/goexif/exif 库
f, _ := os.Open(path)
x, _ := exif.Decode(f)
date, _ := x.DateTime()
return date
}
然后按时间排序,保证视频是从1月1日播到12月31日,这一步很关键——时间线乱了,回忆就乱了。
第三步:逐帧生成视频(每张照片0.5秒就够了)
365张照片,如果每张显示3秒,视频就18分钟了,没人会看完,我设成每张照片 5秒,这样总时长才 3分多一点,节奏感刚刚好,用Go生成FFmpeg命令:
func createVideo(photos []Photo, outputPath string) {
// 先生成一个文件列表
var listContent bytes.Buffer
for _, p := range photos {
listContent.WriteString(fmt.Sprintf("file '%s'\n", p.Path))
listContent.WriteString(fmt.Sprintf("duration %.2f\n", 0.5))
}
ioutil.WriteFile("filelist.txt", listContent.Bytes(), 0644)
// 用concat demuxer拼接
err := ffmpeg_go.Input("filelist.txt", ffmpeg_go.KwArgs{"f": "concat", "safe": "0"}).
Output(outputPath, ffmpeg_go.KwArgs{"c:v": "libx264", "pix_fmt": "yuv420p"}).
OverWriteOutput().Run()
}
这里有个小坑:FFmpeg的concat demuxer要求文件列表严格格式,我第一次运行时视频全是黑的,排查半天发现是列表里多了个换行符,用 strings.TrimSpace 处理一下就解决了。
第四步:加平滑转场(不然像幻灯片)
纯硬切太生硬了,我用FFmpeg的 xfade 滤镜做交叉溶解,每张照片重叠0.1秒,过渡很自然:
func addTransitions(inputVideo string) {
// 这个比较复杂,需要重新编码
// xfade 滤镜参数: transition duration offset
// crossfade 持续0.2秒
filter := "xfade=transition=fade:duration=0.2:offset=0.3"
ffmpeg_go.Input(inputVideo).
Filter("xfade", ffmpeg_go.Args{filter}).
Output("output_with_trans.mp4").Run()
}
但注意,xfade 滤镜对每次只能处理两个片段,所以要用 多个滤镜链,如果365张照片,就需要364个xfade嵌套,手写会疯,我写了个循环生成滤镜字符串:
func buildFilterGraph(count int) string {
// 生成类似 [0][1]xfade=... [v1]; [v1][2]xfade=... [v2]; ... 的字符串
// 这里省略具体实现,有点绕
}
这段代码调试了我两个晚上,最后发现是 offset计算 有问题,每张照片0.5秒,转场0.2秒,offset应该是0.3才对。
效果对比表格:手动剪辑 vs Go脚本
| 维度 | 手动手工剪辑 | Go脚本自动化 |
|---|---|---|
| 处理365张照片时间 | 2小时 | 3分12秒 |
| 是否易出错 | 容易选错顺序或漏掉 | 程序化,零遗漏 |
| 可重复性 | 每次都得重来 | 改个参数就行 |
| 视频质量 | 取决于剪辑水平 | 固定FFmpeg预设 |
| 学习成本 | 需要会Video editing软件 | 需要会Go基础语法 |
小心:内存也能爆
365张照片,如果全部读入内存,按每张10MB算就是3.6GB,再叠加FFmpeg的内存开销,我的16GB Mac差点挂掉,后来改成 流式处理:用Go的 io.Reader 管道,一张一张喂给FFmpeg,内存占用降到200MB以下:
// 用管道代替文件列表
reader, writer := io.Pipe()
go func() {
for _, p := range photos {
data, _ := ioutil.ReadFile(p.Path)
writer.Write(data)
}
writer.Close()
}()
cmd.Stdin = reader
这个方法来自FFmpeg的 pipe 协议,比写临时文件高效多了。
最终结果:一年的压缩感
跑完脚本,生成的视频 3分24秒,配了点背景音乐(用Go的 os/exec 调用FFmpeg的audio filter),画面从冬天的雪人过渡到春天的樱花,再到夏天的海滩、秋天的红叶,最后又回到雪人——但是位置变了,那种感觉,怎么说呢,一年的时间被压缩成了呼吸的长度。
最神奇的是,我在第203张照片里看到了自己在夏天吃西瓜的照片,前面还是穿着羽绒服的,这种对比用视频表现出来,冲击力远超相册。
其实还没完
后来我把这个工具改进了一版,加了个功能:自动识别人脸,把有人的照片放慢到1秒,没人的风景0.5秒,用的 pigo 库,纯Go实现,这样视频就有了重点,不会全程匀速流水账。
现在这个工具放在我笔记本的 ~/tools/photo2video/ 目录下,每年12月31日跑一次,生成当年的"时间压缩"视频,发朋友圈,效果炸裂,朋友问我怎么做的,我说:用Go,他们以为Go是某个视频app的名字。
其实没什么,就是365张照片,一天一天,变成了一分钟的视频,时间就是这么被编码,再被解码的。

下一篇:视频稳定,365天的日常守护