当前位置:首页 > 体育 > 正文

用Golang给TA写个在一起365天视频模板,代码比情话还实在

  • 体育
  • 2026-08-04 14:58:52
  • 65
摘要: 为什么非要用Golang搞这个视频模板?兄弟,你是不是也刷到过那种“恋爱365天纪念日”的短视频?满屏粉红泡泡,背景音乐一响,照...

为什么非要用Golang搞这个视频模板?

兄弟,你是不是也刷到过那种“恋爱365天纪念日”的短视频?满屏粉红泡泡,背景音乐一响,照片一张张翻过去,但你想过没有——这些视频背后,其实全是重复劳动,今天这个模板,明天那个模板,换个照片换个名字,本质都一样。

我去年跟女朋友在一起第300天的时候,就想做个专属视频,但用剪映吧,手动拖素材能把我手指头磨出茧子;用Python吧,她又不懂代码,以后想自己改点东西还得求我,最后我拍板:用Go写个命令行工具,一条命令生成完整视频,为啥?因为Go编译出来是个干净的可执行文件,她双击就能跑,不需要装Python环境,不需要配依赖。

这就是我写这个模板的初衷——用工程思维解决浪漫问题,今天把这套东西拆开给你看,顺便聊聊怎么用Golang把“365天”这个数字玩出花来。

先理清楚:这个模板到底要产出什么?

动手写代码之前,你得先搞清楚需求,我当初列了个清单:

  • 输入:一对情侣的照片(按日期命名,2024-01-15.jpg
  • 输出:一个MP4视频,每张照片停留3秒,底部滚动显示“第XX天”的文字
  • 附加:开头有标题“我们在一起的第365天”,结尾有“未完待续”
  • 技术指标:1080p分辨率,30fps,兼容主流手机播放

看着简单吧?但真要实现,坑不少,比如Go标准库不直接支持视频编码,你得借助FFmpeg,这里我推荐用 github.com/u2takey/ffmpeg-go 这个库,封装得挺干净,比直接调命令行参数强多了。

第一步:用Go生成照片序列的“时间轴”

你别想着一口气生成视频,FFmpeg吃的是图片序列+音频流,所以咱们得先把照片按时间顺序排好,生成一个文本清单。

func buildImageList(dir string) ([]string, error) {
    files, _ := filepath.Glob(filepath.Join(dir, "*.jpg"))
    sort.Strings(files) // 按文件名排序,因为日期在文件名里
    return files, nil
}

但这里有个坑:如果照片是 2024-01-02.jpg2024-01-10.jpg,字符串排序没问题,可要是你哪天漏拍了,日期断档了,那就得处理“第X天没有照片”的情况,我当时的策略是:没照片的日子用上一张重复,但打个半透明水印“这天偷懒没拍”,哈哈,真实生活嘛,总有那么几天懒得化妆。

第二步:动态生成文字渲染——用Go的image包画字幕

FFmpeg的drawtext滤镜虽好,但中文字体路径乱七八糟的,在Windows上尤其麻烦,我干脆用Go的 image + image/draw 包,先把文字画到PNG上,再把PNG作为覆盖层叠到照片上。

func drawTextOverlay(imgPath string, day int, outputPath string) {
    // 打开原图
    img, _ := loadImage(imgPath)
    // 创建画布,尺寸一样
    canvas := image.NewRGBA(img.Bounds())
    // 底部画个半透明黑条
    draw.Draw(canvas, canvas.Bounds(), &image.Uniform{C: color.RGBA{0,0,0,180}}, image.Point{}, draw.Over)
    // 写文字:"Day 365"
    font := loadFont("msyh.ttf") // 微软雅黑,Windows自带
    point := ftfont.NewFace(font, &ftfont.Options{Size: 48})
    drawText(canvas, fmt.Sprintf("Day %d", day), point, 50, 100)
    // 保存为新图
    savePNG(outputPath, canvas)
}

这里有个细节得注意:加载字体,Go的 golang.org/x/image/font 包需要你提供字体文件路径,Windows上直接拿 C:\Windows\Fonts\msyh.ttf 就行,但要判断文件存在,Mac上就是 /System/Library/Fonts/PingFang.ttc,你要是怕麻烦,去GitHub搜个开源中文字体放项目里也行,但注意版权——思源黑体是开源可商用的,推荐。

第三步:让FFmpeg把图片序列变成视频

到这一步,你已经有了所有带日期的PNG图片,接下来就是调用FFmpeg合成视频,用 ffmpeg-go 库,代码比命令行还清晰:

func buildVideo(images []string, output string) {
    err := ffmpeg.
        Input("frames/%06d.png", ffmpeg.KwArgs{"framerate": 1}).
        Output(output, ffmpeg.KwArgs{
            "c:v": "libx264",
            "pix_fmt": "yuv420p",
            "vf": "scale=1920:1080",
            "r": 30,
        }).
        OverWriteOutput().
        Run()
    if err != nil {
        log.Fatal("视频生成失败:", err)
    }
}

但这里有个大坑:帧率与停留时间的矛盾,如果你把每张图片设为1帧,那播放速度就是1秒1张,想每张停留3秒?两种办法:

  • 把帧率设为1/3,但FFmpeg不支持小数帧率
  • 更稳妥:每张照片在Go里重复生成3帧,然后总帧率设30,那么每张实际播放就是3秒

我一开始用的第一种,结果视频播放时一顿一顿的,后来改成第二种,顺畅多了,代价是磁盘空间多了点,但咱不在乎那几百MB。

第四步:加上音乐和转场效果——别让视频干巴巴的

光有照片和文字太单调了,我从 freepd.com 下了一首CC0授权的轻音乐,用FFmpeg混流进去:

func addAudio(videoPath, audioPath, outputPath string) {
    err := ffmpeg.
        Input(videoPath).
        Input(audioPath).
        Output(outputPath, ffmpeg.KwArgs{
            "c:v": "copy",
            "c:a": "aac",
            "shortest": true,
        }).
        OverWriteOutput().
        Run()
}

转场效果呢?我偷懒了——没用交叉淡化,而是用Go在两张图之间生成中间帧(比如从黑到亮渐入),方法也不难:读取两张图,线性插值像素值,生成10帧过渡帧,效果嘛,有点类似老式幻灯片切换,但胜在代码简单。

进阶玩法:把“365天”变成一个可配置参数

你可能会说:“我只想做100天的视频,难道要改代码?”不用,我写了个 config.json

{
  "totalDays": 365,: "我们的365天",
  "subtitle": "从春天到下一个春天",
  "fontPath": "fonts/source-han-sans.otf",
  "photoDir": "./photos",
  "outputDir": "./output",
  "musicPath": "./music/romantic.mp3",
  "resolution": "1920x1080",
  "framePerPhoto": 90
}

主程序读取配置,

type Config struct {
    TotalDays    int    `json:"totalDays"`       string `json:"title"`
    Subtitle     string `json:"subtitle"`
    FontPath     string `json:"fontPath"`
    PhotoDir     string `json:"photoDir"`
    OutputDir    string `json:"outputDir"`
    MusicPath    string `json:"musicPath"`
    Resolution   string `json:"resolution"`
    FramePerPhoto int   `json:"framePerPhoto"`
}

你只要改这个JSON,不管做30天还是1000天,都行。

写完代码,我踩过哪些坑?

  • 中文路径:Windows下如果照片文件夹在桌面,路径里有中文,FFmpeg可能乱码,解决方案:全部用相对路径,或转成 分隔的绝对路径,并加上 filepath.ToSlash()
  • 内存溢出:如果你有几千张照片,一次性加载所有图片再处理,内存直接爆炸,我改成流式处理:处理完一张,写入临时文件夹,释放内存。
  • 时间戳精度:如果你拍了照片但Exif信息不准,按文件名排序可能乱,我加了 time.Parse 解析文件名,如果解析失败就回退到Exif,要是还没,就归入“未分类”文件夹。

实战演示:一条命令生成36个视频碎片

我女朋友有个习惯,喜欢把每个月的纪念视频单独存,我做了个变体:按月份分割,每个月生成一个短视频,最后再合并,用Go的 filepath.Walk 按月分组,然后并发调用 buildVideo——Go的goroutine这时候就派上用场了。

var months map[string][]string
for _, path := range files {
    monthKey := path[:7] // 2025-01
    months[monthKey] = append(months[monthKey], path)
}
var wg sync.WaitGroup
for k, paths := range months {
    wg.Add(1)
    go func(key string, ps []string) {
        defer wg.Done()
        buildVideo(ps, fmt.Sprintf("output/%s.mp4", key))
    }(k, paths)
}
wg.Wait()

6核CPU下,36个月份视频并行生成,总共花了不到两分钟,要是单线程,得等十分钟。

把视频变成“可编程”的:加入日期计数动画

最后我想加个细节:在视频右下角实时显示“在一起第X天”的计数,不是死文字,而是每天自动更新的数字动画,这其实是用Go生成一个 counter_frames 目录,里面存了从1到365的PNG数字,然后FFmpeg循环播放这个目录作为覆盖层。

func generateCounter() {
    for i := 1; i <= 365; i++ {
        canvas := generateDigitImage(i)
        savePNG(fmt.Sprintf("counter/%04d.png", i), canvas)
    }
}

然后用FFmpeg的 overlay 滤镜把视频和计数器叠加:

err := ffmpeg.
    Input("base.mp4").
    Input("counter/%04d.png").
    FilterComplex("overlay=W-w-20:H-h-20").
    Output("final.mp4").
    Run()

这个计数器动画是独立于照片的,也就是说,哪怕你某天没拍照,计数器照常走,这就把“365天”变成了一个动态存在感,而不是被动记录。

给代码加点“温度”:错误提示也必须温柔

日常写代码,报错信息都是一脸冷漠,我重写了错误处理,让程序在崩溃时输出:

func gracefulError(err error) {
    if err != nil {
        fmt.Println("哎,遇到点小麻烦:", err)
        fmt.Println("不过别慌,检查一下照片文件名是不是都是 'YYYY-MM-DD.jpg' 这样的格式")
        os.Exit(1)
    }
}

她第一次跑的时候,照片命名里带了个 _副本 后缀,程序弹出了这个提示,她顺着看了一下文件名,立刻明白怎么改了。有温度的报错,比干巴巴的日志强一万倍

把EXE打包给不懂代码的她

项目做好后,我交叉编译了Windows版本:

CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -o daily-video.exe

然后我把 config.jsondaily-video.exe 放到一个文件夹里,Zip压缩,发给她,她只需要解压,双击exe,等一分钟,视频就躺在 output 文件夹里了。

这种“零门槛交付”的感觉,特别像你为伴侣精心准备了一份礼物,但礼物的打开方式却毫不费力,她甚至能自己换照片、换音乐,每次生成都有新鲜感——有时候她为了让我惊喜,偷偷往文件夹里塞两张新照片,然后跟我说“帮我生成一下”。

用Golang给TA写个在一起365天视频模板,代码比情话还实在