用Go语言打造专属的1到365天纪念日视频,从零到一的全流程指南
- 房产
- 2026-08-27 03:09:00
- 89
为什么我偏偏要用Go写纪念日视频?
说实话,最早我考虑过Python,也试过Node.js,但最后真正让我下定决心用Go的,是它那让人又爱又恨的编译速度和部署简单——编译完就是一个二进制文件,扔到服务器上就能跑,连依赖都不用担心,你说气不气人?
纪念日这种东西,1到365天,每一个数字背后都是一段故事,而我们要做的,就是让代码替我们把这份情感自动化地表达出来,这就要求程序必须稳定、高效,还要能处理各种媒体文件,Go的ffmpeg绑定库和强大的并发模型,正好完美契合这个场景。
第一步:理清你的纪念日视频到底要什么
在写一行代码之前,我强烈建议你先坐下来,认真想明白这三个问题:
- 素材从哪来? 是手机相册的照片,还是视频片段?
- 模板什么样? 是简单照片轮播,还是带转场特效的短视频?
- 输出到哪里? 微信朋友圈9:16竖屏?还是抖音的1080x1920?或者是B站横屏?
不要急着写代码,我当初就是急着动手,结果写了两百行才发现方向错了,全部推翻重来,那种感觉,就像你花了整整一天给女朋友挑礼物,结果发现她想要的根本不是那个。
第二步:搭建Go项目的骨架
咱们先用最朴素的方式建个项目结构:
anniversary_video/
├── main.go
├── config/
│ └── config.json
├── internal/
│ ├── fetcher/ // 拉取图片视频素材
│ ├── renderer/ // 调用ffmpeg生成视频
│ └── notifier/ // 推送通知(微信/邮件)
└── assets/ // 存放静态素材
在main.go里,我们先搞一个简单的配置加载:
package main
import (
"encoding/json"
"fmt"
"os"
)
type Config struct {
DayCount int `json:"day_count"`
SourceDir string `json:"source_dir"`
OutputDir string `json:"output_dir"`
VideoWidth int `json:"video_width"`
VideoHeight int `json:"video_height"`
}
func LoadConfig(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("读取配置失败: %v", err)
}
var cfg Config
if err := json.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("解析配置失败: %v", err)
}
return &cfg, nil
}
别小看这个配置文件,它能让你的程序灵活十倍,第30天想用横屏模板,第100天想用竖屏,改改JSON重启一下就行,不用重新编译。
第三步:素材的获取与整理(这是最痛苦的环节)
好吧,我承认这一步最无聊,但没有办法,素材收集是整个流程的地基,地基不稳,后面全白搭。
图片素材的命名规范
我强烈建议你把所有素材按照YYYYMMDD_序号_描述.jpg的格式重命名,这样在代码里排序、筛选都会很方便,比如20250101_01_sunrise.jpg,代表2025年1月1日的第一张照片,描述是日出。
在internal/fetcher/fetcher.go里,我们这样写:
package fetcher
import (
"os"
"path/filepath"
"sort"
"strings"
)
type MediaItem struct {
Path string
CaptureTime string // 拍摄时间
Order int
}
// ScanSource 扫描源目录,按时间排序
func ScanSource(dir string) ([]MediaItem, error) {
var items []MediaItem
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" && ext != ".mp4" {
return nil
}
// 从文件名解析出时间和序号
base := filepath.Base(path)
parts := strings.Split(base, "_")
if len(parts) >= 2 {
items = append(items, MediaItem{
Path: path,
CaptureTime: parts[0],
Order: atoiSafe(parts[1]),
})
}
return nil
})
if err != nil {
return nil, err
}
sort.Slice(items, func(i, j int) bool {
if items[i].CaptureTime == items[j].CaptureTime {
return items[i].Order < items[j].Order
}
return items[i].CaptureTime < items[j].CaptureTime
})
return items, nil
}
你看,Go的filepath.Walk就是为这种目录遍历而生的,简单粗暴又高效。
第四步:核心环节——用Go调用ffmpeg生成视频
终于到重头戏了,这里我们用os/exec包来调用系统的ffmpeg命令,为什么不直接用纯Go的库呢?因为...说实话,Go的生态里还没有一个能跟ffmpeg抗衡的视频处理库,与其在轮子上做文章,不如直接用ffmpeg这个强大的引擎。
生成照片轮播视频
假设我们有30张照片,想要每张照片显示2秒,加上转场效果,最后合成一个带背景音乐的视频,ffmpeg命令大概长这样:
package renderer
import (
"fmt"
"os/exec"
"path/filepath"
)
type RenderOptions struct {
ImageList []string
OutputPath string
Duration int // 每张图片时长
MusicPath string
Width int
Height int
}
func RenderSlideshow(opts RenderOptions) error {
// 构建ffmpeg输入参数
args := []string{"-y"}
// 循环添加每张图片作为输入
for _, img := range opts.ImageList {
args = append(args, "-loop", "1", "-t", fmt.Sprintf("%d", opts.Duration), "-i", img)
}
// 添加背景音乐
if opts.MusicPath != "" {
args = append(args, "-i", opts.MusicPath)
}
// 复杂滤镜图,这里简化处理
filter := ""
// ... (转场滤镜稍微复杂,我这里直接简化)
// 其实这里我建议直接用concat协议,但转场效果就没了
// 要转场就得用xfade滤镜,但xfade只能两两处理
cmd := exec.Command("ffmpeg", args...)
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("ffmpeg执行失败: %v, 输出: %s", err, string(output))
}
return nil
}
呃,写到这里我突然发现代码太长了,实际上在生产环境里,我一般会用filter_complex来搞转场,但那个代码写起来真的会把人绕晕,这里分享个偷懒小技巧:直接用Zoompan效果替代转场,虽然效果没那么炫酷,但胜在代码简洁,而且看起来也蛮自然的。
处理图片的缩放与裁剪
还有一个关键点:图片尺寸可能不一致,直接丢给ffmpeg会拉伸变形,所以我们要先做一个预处理,把图片统一到目标尺寸,用Go的image包加上golang.org/x/image/draw扩展库:
import (
"image"
"image/jpeg"
"os"
"golang.org/x/image/draw"
)
func ResizeImage(srcPath, dstPath string, width, height int) error {
f, err := os.Open(srcPath)
if err != nil {
return err
}
defer f.Close()
img, _, err := image.Decode(f)
if err != nil {
return err
}
// 等比缩放并裁剪到目标尺寸
dst := image.NewRGBA(image.Rect(0, 0, width, height))
draw.CatmullRom.Scale(dst, dst.Bounds(), img, img.Bounds(), draw.Over, nil)
out, err := os.Create(dstPath)
if err != nil {
return err
}
defer out.Close()
return jpeg.Encode(out, dst, &jpeg.Options{Quality: 90})
}
别小看这个预处理,处理不好,生成出来的视频会让人有一种“用脚拍的”感觉,生命中的每一个纪念日,都值得被高清地记录,对吧?
第五步:全程自动化——定时任务与触发机制
纪念日视频最大的价值在于自动化,不能每天手动跑一次程序,那就没意思了。
用cron做定时
在Linux服务器上,用cron最简单:
# 每天凌晨1点检查并生成当天的纪念日视频
0 1 * * * /usr/local/bin/anniversary_video --config /etc/anniversary/config.json
但如果你的纪念日不是每天都有(比如只有第50天、第100天这种),那就在程序里判断:
func ShouldGenerateToday(cfg *Config) bool {
startDate := time.Date(2024, 1, 1, 0, 0, 0, 0, time.Local)
today := time.Now()
dayDiff := int(today.Sub(startDate).Hours() / 24) + 1
// 每隔7天生成一次
return dayDiff%7 == 0
}
一个完整的调度逻辑
用Go的time.Ticker搞个轻量级的轮询:
func RunScheduler(checkInterval time.Duration) {
ticker := time.NewTicker(checkInterval)
defer ticker.Stop()
for range ticker.C {
if ShouldGenerateToday(cfg) {
go func() {
// 生成视频
items, _ := fetcher.ScanSource(cfg.SourceDir)
opts := renderer.RenderOptions{
ImageList: getPaths(items),
OutputPath: filepath.Join(cfg.OutputDir,
fmt.Sprintf("anniversary_%d.mp4", today.Day())),
Duration: 2,
Width: cfg.VideoWidth,
Height: cfg.VideoHeight,
}
renderer.RenderSlideshow(opts)
}()
}
}
}
注意啊,这里用了go func()异步执行,不然会阻塞主循环,但是要注意并发控制,别忘了用sync.WaitGroup或者信号量来限制并发数。
第六步:添加一点人文关怀的细节
光有视频还不够,一个好的纪念日视频还得有文字、音乐、甚至片头片尾。
在视频中叠加文字
用draw库在图片上写点字,第100天”、“亲爱的,谢谢你陪我走过”:
func AddTextToImage(img *image.RGBA, text string) {
// 使用基本字体
point := fixed.Point26_6{
X: int26_6(img.Bounds().Dx()/2 - 50),
Y: int26_6(img.Bounds().Dy() - 100),
}
d := &font.Drawer{
Dst: img,
Src: image.NewUniform(color.RGBA{255, 255, 255, 255}),
Face: basicfont.Face7x13,
Dot: point,
}
d.DrawString(text)
}
这个字体效果比较简陋,但能应付大多数场景,要好看的话,可以加载fogleman/gg库或者用免费的中文字体文件。
根据天数变化调整内容
这就得提到配置文件里的day_count字段了,第1天和第365天,肯定要用不同的音乐和文案,我在配置里就专门加了一个templates数组,根据天数匹配:
{
"templates": [
{
"min_day": 1,
"max_day": 30,
"music": "初识.mp3",
"subtitle": "初遇的美好,每一天都是惊喜"
},
{
"min_day": 100,
"max_day": 200,
"music": "深爱.mp3",
"subtitle": "百日之约,风雨同舟"
}
]
}
第七步:部署与运维的坑(我替你踩过了)
内存问题:ffmpeg在处理高清视频时非常吃内存,我建议在生成视频前先检查一下系统可用内存,不然服务器会OOM,用Go的runtime.ReadMemStats()可以获得Go程序的内存使用,但这是针对Go进程本身的,更靠谱的是用syscall.Sysinfo来获取系统总内存:
func CheckMemory() bool {
var info syscall.Sysinfo_t
err := syscall.Sysinfo(&info)
if err != nil {
return false
}
// 可用内存小于500MB就停止执行
return info.Freeram > 500*1024*1024
}
磁盘清理:生成的视频文件会很占空间,一定要做好定时清理策略,我一般只保留最近7天的视频,用cron定期删除,但不要用rm -rf啊,万一删错了呢?用os.Remove + 路径拼接,安全又干净。
日志记录:打印日志是必须的,我用的是Go标准库的log包,加上log.LstdFlags标志:
log.SetFlags(log.LstdFlags | log.Lshortfile)
log.Printf("开始生成第 %d 天纪念视频", dayNum)
这些日志对于排查问题特别重要,尤其是当用户(女朋友)说“视频没生成”的时候,你要能迅速定位是哪一步出了问题,相信我,你一定会遇到这种时候的。
最后的最后,关于如何让视频更打动人
技术层面说完了,来聊聊非技术层面的,纪念日视频之所以特别,是因为它承载了时间的重量,我在设计程序的时候,除了自动拼接素材,还会在视频结尾加上一个倒计时的画面——距离下一个纪念日还有多少天。
实现起来也不难,就是在素材列表的最后,动态生成一张带有文字的图片:
func GenerateCountdownImage(days int) string {
img := image.NewRGBA(image.Rect(0, 0, 1080, 1920))
// 画个渐变背景,加行字
...
// 保存为临时文件
tmpPath := filepath.Join(os.TempDir(), "countdown.png")
...
return tmpPath
}
这样,每次生成的视频就会有一个意外的“彩蛋”,也许多年后回看,你会被当时的每一个细节所触动——包括那些你当时没注意到的、自动加上的小元素。
好了,这就是我用Go写1到365天纪念日视频的完整经验,代码坑坑洼洼的,但每一步都是真实的,现在轮到你动手了——找一个安静的新手村,把Go环境配置好,建个目录,开始你的第一个go run main.go,相信我,当第一个视频成品出来的那一刻,你会觉得之前掉过的所有头发都是值得的。
就像那句话说的一样,每一个纪念日都是时间的坐标,而我们只是用代码做了个小小的标记,让那些瞬间能被我们一遍遍地找回,祝你玩得开心。
