一年,365天,说长不长,说短不短,前几天我女朋友突然问我:“咱们快一周年了,能不能做个视频,把这一年每一天的照片串起来?”我第一反应是去搜现成的App,结果发现要么要会员,要么模板丑得不行,要么导出的视频带水印,算了,自己写吧,正好最近在学习Go,就顺手用Go造了一个生成一周年纪念视频365的小工具。
你可能会问:Go不是写后端服务的吗?做视频?能行吗?其实Go在多媒体处理这块儿,虽然不像Python那么主流,但配合FFmpeg,干这事儿一点都不含糊,而且跨平台,编译出来一个二进制文件,扔给谁都能用。
为什么选Go来干这个“视频活儿”
坦白说,最开始我也想过用Python,但有几个点让我最终选了Go:
- 部署简单,编译出来一个
anniversary-video.exe,双击就能跑,不用装Python环境,不用装一堆库,女朋友电脑上啥都没有,给她一个exe文件就完事了。 - 并发能力强,一年365张照片,如果一张一张处理,够慢的,Go的goroutine天然适合做这种图片批量处理。
- 内存管理靠谱,视频处理涉及大量内存操作,Go的垃圾回收机制比Python手动释放要省心得多。
Go也有短板,比如纯Go的图片处理库不如Python的Pillow丰富,但没关系,我们可以“曲线救国”——Go负责调度和逻辑,FFmpeg负责编码,ffmpeg负责合并,这种组合拳打出来,效果意外地好。
核心思路:把“365张照片”变成“1个视频”
这个一周年纪念视频365项目的核心逻辑,说白了就三步:
- 把每张照片处理成统一尺寸(比如1920x1080)
- 给每张照片加上日期水印和一段小文字
- 把所有照片拼成一个视频,每张照片停留3秒,配上背景音乐
听起来简单对吧?但真正写起来,细节还挺多的,我们一点一点来。
第一步:准备照片,按日期整理
我假设你已经把这一年的照片按日期命名好了,比如2024-09-01.jpg、2024-09-02.jpg这样,如果没有,也可以用文件夹分组。
代码里我写了一个扫描函数,读一个目录下的所有图片文件,然后按文件名中的日期排序:
// 假装这是Go代码,实际上我会给出完整可运行的版本
func loadPhotos(dir string) ([]Photo, error) {
files, err := ioutil.ReadDir(dir)
if err != nil {
return nil, err
}
var photos []Photo
for _, f := range files {
if isImageFile(f.Name()) {
// 从文件名提取日期
date := extractDateFromFilename(f.Name())
photos = append(photos, Photo{
Path: filepath.Join(dir, f.Name()),
Date: date,
})
}
}
// 按日期排序
sort.Slice(photos, func(i, j int) bool {
return photos[i].Date.Before(photos[j].Date)
})
return photos, nil
}
这里有个小坑:ioutil.ReadDir在Go 1.16之后被弃用了,改成用os.ReadDir,不过没关系,功能一样,排序的时候用sort.Slice,Go 1.18以后支持泛型,但这里用反射sort也能搞定。
第二步:图片处理——加水印、调尺寸
图片处理这块,Go标准库就有image和image/draw包,可以完成基本的缩放、绘制文字,但说实话,Go内置的字体渲染比较简陋,想画漂亮的文字还得用第三方库。
我选的是golang.org/x/image/font和golang.org/x/image/font/basicfont,配合github.com/golang/freetype来渲染TrueType字体,如果你不想折腾字体,也可以用github.com/nfnt/resize做图片缩放。
来看一段实际处理图片的代码:
func processPhoto(inputPath, outputPath string, date time.Time, index int) error {
// 打开原始图片
src, err := loadImage(inputPath)
if err != nil {
return err
}
// 缩放到1920x1080,保持比例,不足部分补黑边
dst := resizeToFit(src, 1920, 1080)
// 在图片上画日历框和日期
drawCalendar(dst, date)
// 在右下角画“第X天”的标记
drawDayLabel(dst, index+1)
// 保存处理后的图片
return saveImage(dst, outputPath, 95) // 95%质量
}
这里resizeToFit函数我用了github.com/nfnt/resize包,它支持Lanczos插值,缩放质量很高。drawCalendar函数是核心,它会在图片左上角画一个半透明背景的日历图标,下面写上“2024年9月1日”这样的格式。
第三步:合成视频——Go + FFmpeg双剑合璧
照片处理完,下一步就是合成视频,这一步才是最考验人的。
Go本身没有直接合成视频的能力,但我们可以用os/exec来调用FFmpeg,FFmpeg是视频处理的瑞士军刀,装好之后,在命令行里一行命令就能把一堆图片合成视频:
ffmpeg -r 1/3 -i processed_%04d.jpg -c:v libx264 -r 30 -pix_fmt yuv420p output.mp4
这个命令的意思是:-r 1/3表示每张图片显示3秒,-i processed_%04d.jpg表示输入文件命名格式为processed_0001.jpg、processed_0002.jpg这样,-c:v libx264用H.264编码,-r 30输出30帧每秒,-pix_fmt yuv420p保证兼容性。
但手动敲命令太傻了,我们要在Go程序里自动执行这个命令,并且加上背景音乐、片头片尾。
下面是用Go调用FFmpeg的核心代码:
func renderVideo(imageDir, outputPath, musicPath string) error {
// 先生成图片列表文件
listFile := "filelist.txt"
err := generateFileList(imageDir, listFile)
if err != nil {
return err
}
defer os.Remove(listFile)
// 构建FFmpeg命令
args := []string{
"-y", // 覆盖输出文件
"-f", "concat",
"-safe", "0",
"-i", listFile,
"-i", musicPath,
"-c:v", "libx264",
"-preset", "medium",
"-crf", "18", // 高质量
"-c:a", "aac",
"-shortest", // 以视频长度为准
"-pix_fmt", "yuv420p",
outputPath,
}
cmd := exec.Command("ffmpeg", args...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}
这里我用了一个小技巧:concat demuxer,它需要一个文件列表,每行写file 'path_to_image',然后duration 3,这样就可以精确控制每张照片的显示时长。
但要注意,FFmpeg的concat demuxer对图片格式有要求,最好是PNG或JPEG连续序号,如果你处理后的图片不是连续命名的,可以用生成文件列表的方式。
第四步:加入片头片尾和转场效果
一周年纪念视频嘛,怎么能没有片头和片尾?我设计了一个简单但温馨的片头:黑色背景上,用白色字体写着“这是我们的一年”,然后渐变到第一张照片。
实现方式其实不复杂:用Go生成一段纯色图片作为片头,再生成一段带文字的图片作为片尾,然后跟照片一起合成,或者更简单一点,用FFmpeg的drawtext滤镜直接渲染文字。
// 生成片头图片
func generateIntroImage(outputPath string) error {
img := image.NewRGBA(image.Rect(0, 0, 1920, 1080))
// 填充黑色背景
draw.Draw(img, img.Bounds(), &image.Uniform{color.Black}, image.Point{}, draw.Src)
// 画文字
addText(img, "这是我们的一年", 960, 540, 72, color.White)
return saveImage(img, outputPath, 95)
}
转场效果这块,我偷了个懒,没有做复杂的淡入淡出——因为FFmpeg的fade滤镜需要在两张图片之间插入过渡帧,手动处理很麻烦,我选择用FFmpeg的xfade滤镜,它支持几十种转场效果,用法是这样的:
ffmpeg -i input1.jpg -i input2.jpg -filter_complex "xfade=transition=fade:duration=0.5:offset=2.5" output.mp4
但这样处理365张图片,命令会非常长,我写了一个函数,自动生成FFmpeg的filter_complex字符串。
实战:把代码跑起来
好了,理论说得差不多了,咱们直接上手跑一遍,我整理了一个完整的项目结构:
anniversary-video/
├── main.go # 主逻辑
├── photo.go # 图片加载和处理
├── watermark.go # 水印和日历绘制
├── video.go # 视频合成
├── config.go # 配置参数
└── fonts/
└── NotoSansSC-Regular.ttf # 中文字体
主程序入口很简洁:
func main() {
fmt.Println("开始制作一周年纪念视频...")
// 1. 加载照片
photos, err := loadPhotos("./photos")
if err != nil {
log.Fatal("加载照片失败:", err)
}
fmt.Printf("找到 %d 张照片\n", len(photos))
// 2. 处理照片(并发处理)
err = processAllPhotos(photos, "./processed", 10) // 10个goroutine并发
if err != nil {
log.Fatal("处理照片失败:", err)
}
fmt.Println("照片处理完成")
// 3. 合成视频
err = renderVideo("./processed", "our-anniversary.mp4", "./music/background.mp3")
if err != nil {
log.Fatal("合成视频失败:", err)
}
fmt.Println("视频生成成功: our-anniversary.mp4")
}
并发处理部分,我用了一个worker pool模式:
func processAllPhotos(photos []Photo, outputDir string, workers int) error {
os.MkdirAll(outputDir, 0755)
jobChan := make(chan int, len(photos))
errChan := make(chan error, len(photos))
// 启动workers
for w := 0; w < workers; w++ {
go func() {
for idx := range jobChan {
err := processPhoto(photos[idx].Path,
filepath.Join(outputDir, fmt.Sprintf("frame_%04d.jpg", idx+1)),
photos[idx].Date, idx)
if err != nil {
errChan <- err
}
}
}()
}
// 分发任务
for i := 0; i < len(photos); i++ {
jobChan <- i
}
close(jobChan)
// 收集错误(如果有)
select {
case err := <-errChan:
return err
default:
return nil
}
}
这里有个细节:给输出文件命名一定要用frame_%04d.jpg这种格式,这样FFmpeg才能识别成序列帧,不然的话,就得像我之前说的那样用文件列表。
真实使用中的坑和解决方案
真正给女朋友做视频的时候,踩了这么几个坑,也分享给你:
| 问题 | 现象 | 解决方案 |
|---|---|---|
| 中文乱码 | 图片上的水印文字显示为方框 | 下载思源黑体,用freetype加载字体,不要用默认的basicfont |
| 照片比例不一 | 有些照片被拉伸变形,有些出现黑边 | 用resize.Thumbnail保持比例,用黑色背景填充剩余区域 |
| 内存暴涨 | 处理365张图片时内存占用超过2GB | 不要一次性加载所有图片到内存,用管道或者逐张处理,用sync.Pool复用对象 |
| FFmpeg路径问题 | 用户电脑没装FFmpeg | 在程序启动时检查,如果没有安装,自动从网上下载便携版(或者提示用户安装) |
| 音频长度不匹配 | 视频长度和音乐长度不一致 | 用-shortest参数以视频为准,或者用-t截取音乐到视频长度 |
| 生成视频太大 | 365张高清照片生成几GB | 用-crf 23适当降低质量,或者用-preset veryslow提高压缩率但在意编码时间 |
最崩溃的一次是,我跑了一下午生成好了,结果打开发现前10张照片日期全写错了——我把月份的索引从0开始算,忘了Go的time.Month是int类型,January=0,但实际显示应该+1,这个bug后来在watermark.go里改过来了。

进阶玩法:加入时间线和情感叙事
光是把照片串起来,其实有点单调,我后来加了一个功能:根据照片的拍摄时间自动生成时间线注释。
同一张照片里,如果拍摄时间间隔很短(<30分钟),我就认为是在同一个场景或事件里,然后在视频画面上方显示这个场景的“标题”,2024年10月,我们第一次一起看海”、“2025年1月,过年回家”。
实现方式是用一个简单的聚类算法:把所有照片按时间排序,计算相邻照片的时间差,如果超过2小时就认为是一个新场景,然后给每个场景起个名字(当然这个得手动输入,或者从照片文件名提取)。
type Scene struct {
StartIndex int
EndIndex int
Label string
}
func detectScenes(photos []Photo, threshold time.Duration) []Scene {
var scenes []Scene
start := 0
for i := 1; i < len(photos); i++ {
if photos[i].Date.Sub(photos[i-1].Date) > threshold {
scenes = append(scenes, Scene{start, i-1, ""})
start = i
}
}
scenes = append(scenes, Scene{start, len(photos)-1, ""})
return scenes
}
然后给每个Scene加上情感标签,比如你可以在JSON配置文件里写好:
[
{"index": 0, "label": "初遇,心跳加速"},
{"index": 1, "label": "第一次做饭,糊了但好吃"},
{"index": 2, "label": "吵架后和好的那束花"}
]
这些标签会显示在视频的对应时间段,配合一张带文字的图片,效果特别棒。
最终效果和一点心里话
视频生成后,我打开看了一遍,第一张照片是去年9月1号,在咖啡馆拍的,两个人都有点紧张;慢慢往后看,日常煮饭、周末爬山、凌晨看日出、还有她生气时拍的“认罪书”……最后一张是昨天的合照,视频最后,屏幕缓缓打出“365天,感谢有你”,这个一周年纪念视频365的项目就完成了。
说实话,用Go写这个工具,比我想象中要花时间,但当你看到最终成品,看到女朋友眼眶湿润的那一刻,你觉得那堆代码、踩过的坑、调试到凌晨两点,都值了。一周年纪念视频365不仅是一段视频,它是你们这一年的时光切片,是每一次快门的累积。
如果你也打算用Go来做一个,下面这些资源可能对你有用:
- Golang image包官方文档:处理图片的基础操作
- FFmpeg滤镜文档:
xfade、drawtext、scale等滤镜的详细用法 - golang.org/x/image/font:Go官方图片字体库
- github.com/nfnt/resize:高质量的图片缩放库
代码我就不贴全文了,因为每个人需要的水印样式、日期格式、照片尺寸都不一样,但核心思路就是这些:Go处理图片逻辑 + FFmpeg合成视频,你可以根据自己的需求改改,比如改成3D翻页效果、加滤镜、甚至用AI给每张照片自动配文字。
最后提醒一点:记得提前把照片按日期整理好,并且备份原始文件,我因为忘记备份,误操作覆盖了3月份的照片,还好从手机上找回来了。
希望你也能用Go,在这个充满数字的世界里,为那个对的人,用这365帧,做一台温柔的时间机器。
本文来自作者[kyadmin]投稿,不代表be365立场,如若转载,请注明出处:http://www.hljbesthome.com/jiankang/1383.html
评论列表(4条)
我是be365的签约作者“kyadmin”!
希望本篇文章《用Go语言做一周年纪念视频365,从零开始造一个温暖的时间机器》能对你有所帮助!
本站[be365]内容主要涵盖:be365,be365网站,be365网址,ball365体育,Be365排名
本文概览:一年,365天,说长不长,说短不短,前几天我女朋友突然问我:“咱们快一周年了,能不能做个视频,把这一年每一天的照片串起来?”我第一反应是...