当前位置:首页 > 科技 > 正文

365张照片变视频一天,我用Go语言写了个工具,把一整年的回忆压缩成60秒

  • 科技
  • 2026-06-30 16:30:47
  • 305
摘要: 说实话,最开始我根本没想到这事能成,那天整理手机相册,翻到去年拍的照片——365张,一天一张,突然就想,要是能把这些照片串成一个...

说实话,最开始我根本没想到这事能成,那天整理手机相册,翻到去年拍的照片——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张照片变视频一天,我用Go语言写了个工具,把一整年的回忆压缩成60秒