当前位置:首页 > 房产 > 正文

用Go语言打造专属的1到365天纪念日视频,从零到一的全流程指南

  • 房产
  • 2026-08-27 03:09:00
  • 52
摘要: 为什么我偏偏要用Go写纪念日视频?说实话,最早我考虑过Python,也试过Node.js,但最后真正让我下定决心用Go的,是它那...

为什么我偏偏要用Go写纪念日视频?

说实话,最早我考虑过Python,也试过Node.js,但最后真正让我下定决心用Go的,是它那让人又爱又恨的编译速度部署简单——编译完就是一个二进制文件,扔到服务器上就能跑,连依赖都不用担心,你说气不气人?

纪念日这种东西,1到365天,每一个数字背后都是一段故事,而我们要做的,就是让代码替我们把这份情感自动化地表达出来,这就要求程序必须稳定、高效,还要能处理各种媒体文件,Go的ffmpeg绑定库和强大的并发模型,正好完美契合这个场景。

第一步:理清你的纪念日视频到底要什么

在写一行代码之前,我强烈建议你先坐下来,认真想明白这三个问题:

  1. 素材从哪来? 是手机相册的照片,还是视频片段?
  2. 模板什么样? 是简单照片轮播,还是带转场特效的短视频?
  3. 输出到哪里? 微信朋友圈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,相信我,当第一个视频成品出来的那一刻,你会觉得之前掉过的所有头发都是值得的。

就像那句话说的一样,每一个纪念日都是时间的坐标,而我们只是用代码做了个小小的标记,让那些瞬间能被我们一遍遍地找回,祝你玩得开心。

用Go语言打造专属的1到365天纪念日视频,从零到一的全流程指南