当前位置:首页 > 旅游 > 正文

用Golang打造365天一周岁视频,从零到一的完整指南

  • 旅游
  • 2026-07-01 00:52:10
  • 292
摘要: 你有没有这样的经历?翻看手机里娃的照片,突然发现时间过得真快——从出生那天皱巴巴的小脸,到第一次翻身、第一次爬行、第一声“妈妈”...

你有没有这样的经历?翻看手机里娃的照片,突然发现时间过得真快——从出生那天皱巴巴的小脸,到第一次翻身、第一次爬行、第一声“妈妈”,转眼就快满一周岁了,这些瞬间都值得被记住,但更酷的是,把它们串成一条时间线,让每一天都有一个“打卡”画面,最后合成一部365天一周岁视频。

我当初做这件事的时候,试过各种现成的APP,要么水印太大,要么模板太丑,要么导出分辨率低得可怜,最后我决定——用Golang自己写一个,不是因为我多厉害,而是因为Golang处理视频确实有一套,跨平台、并发强、而且写起来很顺。

别担心你是个新手,我把踩过的坑全告诉你,这篇文章里,我们用Golang从零开始,一步步做出专属于你的365天一周岁视频。

为什么要用Golang做?

我知道你可能会问:为什么不是Python?Python做视频也不少库啊,没错,Python确实可以,但有几个点让我最终选了Go:

对比项 Golang Python
编译执行速度 快(原生编译) 慢(解释执行)
并发处理 原生goroutine 依赖多线程/异步
跨平台部署 单文件编译 依赖环境+库
视频库生态 ffmpeg-go / gstreamer moviepy / opencv

最关键的是——Golang编译后就是一个二进制文件,丢到哪儿都能跑,你做好了,发给家里老人,他们双击就能用,不用装Python环境,这个体验,Python给不了。

还有一个更实际的原因:处理365张图片+365段音频,如果串行处理得等到猴年马月,Go的goroutine可以让这个过程快5~10倍,我自己的测试,一台4核8线程的笔记本,用Go的并发,全流程跑完只要15分钟。

项目结构:先把架子搭好

开始写代码之前,先想清楚我们要做什么,365天一周岁视频,本质上就是:

  1. 每天一张照片(或者视频片段)
  2. 每天一句语音/文字(第X天,宝宝会翻身了”)
  3. 按时间顺序拼接成一个视频
  4. 加个片头、片尾、背景音乐

我用的是这个目录结构:

baby-video/
├── main.go           # 入口文件
├── materials/        # 素材目录
│   ├── photos/       # 按日期命名的照片 001.jpg, 002.jpg ...
│   ├── audio/        # 按日期命名的语音 001.mp3, 002.mp3 ...
│   └── bgm/          # 背景音乐
├── output/           # 输出目录
└── ffmpeg/           # ffmpeg相关操作(如果你装的是系统ffmpeg,这步跳过)

为什么照片和音频要编号?因为我们要排序,数字命名最容易排序,而且出错的概率最低。

第一步:安装依赖和工具

Golang操作视频,离不开ffmpeg,这是底层工具,Go只是调用它,安装很简单:

# MacOS
brew install ffmpeg
# Ubuntu/Debian
sudo apt install ffmpeg
# Windows
# 去ffmpeg官网下载,加到环境变量

然后在Go项目里,我推荐用 u2takey/ffmpeg-go 这个库,封装得比较顺手。

go get github.com/u2takey/ffmpeg-go

这个库最大的好处是——写起来像在写命令行,但又能用Go的变量和流程控制,举个例子:

import ffmpeg "github.com/u2takey/ffmpeg-go"
// 生成一张图片的视频片段(5秒)
func createClip(imagePath string, duration int) *ffmpeg.Stream {
    return ffmpeg.Input(imagePath, ffmpeg.KwArgs{
        "loop": "1",
        "t":    fmt.Sprintf("%d", duration),
    })
}

你看,KwArgs 就是ffmpeg的参数,只是变成了Go的map,你不需要额外学新的视频编程模型。

第二步:整理素材——365天的“小麻烦”

别以为最难的是写代码,真正花时间的是整理素材

照片要统一尺寸和格式

我一开始的照片,有的横屏,有的竖屏,还有21:9的,如果直接拼,视频里就会出现黑边,好的做法是——先全部转成统一尺寸,比如1920x1080(16:9)。

用ffmpeg批量处理:

func resizeImage(inputPath, outputPath string, width, height int) error {
    err := ffmpeg.Input(inputPath).
        Filter("scale", ffmpeg.Args{fmt.Sprintf("%d:%d", width, height)}).
        Output(outputPath).
        OverWriteOutput().
        Run()
    return err
}

但这里有个坑:scale是拉伸,如果你的原图比例不对,画面会变形,更好的做法是用 pad 加黑边保持原比例:

ffmpeg.Input(inputPath).
    Filter("scale", ffmpeg.Args{"1920:1080:force_original_aspect_ratio=1"}).
    Filter("pad", ffmpeg.Args{"1920:1080:(ow-iw)/2:(oh-ih)/2"}).
    Output(outputPath).
    OverWriteOutput().
    Run()

音频也要处理

语音片段长短不一,有的只有2秒,有的10秒,我们统一让每个音频片段 正好和对应的照片时长一致,并且音量调整到统一水平。

func normalizeAudio(inputPath, outputPath string, duration float64) error {
    return ffmpeg.Input(inputPath).
        Filter("volume", ffmpeg.Args{"1.0"}).  // 统一音量
        Filter("atrim", ffmpeg.Args{fmt.Sprintf("duration=%f", duration)}).
        Output(outputPath).
        OverWriteOutput().
        Run()
}

这里有个小技巧:如果音频长度不够,可以用 apad 补空白;如果太长,用 atrim 裁剪。但最好是提前录好每个音频的长度,这样省事很多。

第三步:并发处理——Golang的拿手好戏

365个片段,如果一个个处理,大概要跑90分钟,但用Go的goroutine,只需要加几行代码:

func processAllClips(photos []string, audios []string) ([]string, error) {
    clips := make([]string, len(photos))
    errChan := make(chan error, len(photos))
    sem := make(chan struct{}, 8) // 限制并发数为8,防止炸内存
    var wg sync.WaitGroup
    for i := 0; i < len(photos); i++ {
        wg.Add(1)
        go func(idx int) {
            defer wg.Done()
            sem <- struct{}{} // 获取信号量
            defer func() { <-sem }()
            clipPath := fmt.Sprintf("clip_%03d.mp4", idx)
            err := createSingleClip(photos[idx], audios[idx], clipPath, 3)
            if err != nil {
                errChan <- fmt.Errorf("clip %d failed: %v", idx, err)
                return
            }
            clips[idx] = clipPath
        }(i)
    }
    wg.Wait()
    close(errChan)
    close(sem)
    // 检查是否有错误
    for err := range errChan {
        if err != nil {
            return nil, err
        }
    }
    return clips, nil
}

控制并发数很重要,我一开始没加 sem,8个goroutine同时用ffmpeg,直接把内存吃爆了,设成8个,刚好跑满CPU又不超内存。

这个函数会把365张照片+365段音频,分别合成365个3秒的视频片段,每个片段都是一个独立的mp4文件。

第四步:合并视频——别用concatconcat

合并视频最简单的方式是直接用 ffmpeg -f concat,但有个恶心的限制:所有片段必须编码参数完全一致,分辨率、帧率、编码器、像素格式,差一点都不行。

我的做法是——先用concat demuxer,传入一个文件列表

func mergeClips(clips []string, outputPath string) error {
    // 先生成一个文件列表
    listFile := "files.txt"
    var builder strings.Builder
    for _, clip := range clips {
        builder.WriteString(fmt.Sprintf("file '%s'\n", clip))
    }
    os.WriteFile(listFile, []byte(builder.String()), 0644)
    // 合并
    return ffmpeg.Input(listFile, ffmpeg.KwArgs{"f": "concat", "safe": "0"}).
        Output(outputPath, ffmpeg.KwArgs{"c": "copy"}).
        OverWriteOutput().
        Run()
}

为什么用 c copy?这样不重新编码,速度极快,而且画质无损失,但如果你的片段编码参数不一致,这里就会报错。

解决方案: 在生成片段时,统一指定编码参数

func createSingleClip(imagePath, audioPath, outputPath string, duration int) error {
    return ffmpeg.Input(imagePath, ffmpeg.KwArgs{"loop": "1", "t": fmt.Sprintf("%d", duration)}).
        Input(audioPath).
        Output(outputPath, ffmpeg.KwArgs{
            "c:v":  "libx264",
            "c:a":  "aac",
            "pix_fmt": "yuv420p",
            "r":    "30",  // 30帧每秒
            "b:v":  "2000k",
        }).
        OverWriteOutput().
        Run()
}

第五步:加片头、片尾和背景音乐

到了这一步,你已经有了一个包含365个片段的完整视频(大概1095秒,约18分钟),但还不够特别,我们要加上片头、片尾,以及贯穿全片的背景音乐。

片头怎么做?

片头一般是一张专门设计的图片,配上文字“宝贝365天成长记录”,然后让图片慢慢放大或者移动到正中间。

用ffmpeg的 zoompan 滤镜可以实现推拉效果:

func createIntro(duration int) string {
    introPath := "intro.mp4"
    ffmpeg.Input("intro_bg.jpg", ffmpeg.KwArgs{"loop": "1", "t": fmt.Sprintf("%d", duration)}).
        Filter("zoompan", ffmpeg.Args{"z='if(lte(zoom,1.0),1.5,zoom-0.002)':d=25*4"}).
        Output(introPath, ffmpeg.KwArgs{"c:v": "libx264", "pix_fmt": "yuv420p"}).
        OverWriteOutput().
        Run()
    return introPath
}

背景音乐混音

背景音乐最好选一首舒缓的纯音乐,比如钢琴曲或者摇篮曲,长度最好在5分钟左右——可以循环播放。

func addBGM(videoPath, bgmPath, outputPath string) error {
    // 先获取视频时长
    duration, _ := getVideoDuration(videoPath)
    // 用amovie滤镜让背景音乐循环,音量为原视频的0.3倍(背景)
    return ffmpeg.Input(videoPath).
        Input(bgmPath, ffmpeg.KwArgs{"stream_loop": "-1"}).
        Filter("amovie", ffmpeg.Args{fmt.Sprintf("%s:loop=0", bgmPath)}).
        Filter("volume", ffmpeg.Args{"0.3"}).
        Output(outputPath, ffmpeg.KwArgs{
            "c:v":    "copy",
            "c:a":    "aac",
            "shortest": nil,
        }).
        OverWriteOutput().
        Run()
}

这里 shortest 参数很关键——它让音频和视频里最短的那个决定最终时长,视频是主轨道,所以最终视频长度不变,背景音乐自动截断。

一些让你“哦”的细节

做了这个项目后,我发现了几个容易翻车的地方:

文件名排序的坑

如果文件名是 jpg, jpg, ..., jpg,用字符串排序的话,jpg会在 jpg 前面,一定要用数字排序。

func sortFiles(files []string) {
    sort.Slice(files, func(i, j int) bool {
        // 提取数字部分
        numI, _ := strconv.Atoi(strings.TrimSuffix(strings.TrimPrefix(files[i], "materials/photos/"), ".jpg"))
        numJ, _ := strconv.Atoi(strings.TrimSuffix(strings.TrimPrefix(files[j], "materials/photos/"), ".jpg"))
        return numI < numJ
    })
}

内存管理

处理365个片段,中间文件要占大量磁盘,我建了一个 temp/ 目录,每生成一个片段就写进去,合并完成后全部删除。

defer os.RemoveAll("temp/")  // 程序结束自动清理

进度显示

365个片段,用户等得心焦,加个进度条会好很多。

func showProgress(total int, done chan int) {
    for i := 0; i < total; i++ {
        <-done
        percent := float64(i+1) / float64(total) * 100
        fmt.Printf("\r进度: %.1f%% (%d/%d)", percent, i+1, total)
    }
    fmt.Println("\r进度: 100.0% (完成)")
}

完整的main函数长什么样

我把所有功能组织成一个流水线:

func main() {
    fmt.Println("开始制作365天一周岁视频...")
    // 1. 扫描素材
    photos := scanFiles("materials/photos", ".jpg")
    audios := scanFiles("materials/audio", ".mp3")
    if len(photos) != 365 {
        fmt.Printf("照片数量不对: %d (需要365张)\n", len(photos))
        return
    }
    // 2. 创建temp目录
    os.MkdirAll("temp", 0755)
    // 3. 并发处理所有片段
    clips := processAllClips(photos, audios)
    // 4. 合并片段
    fmt.Println("合并视频片段...")
    mergeClips(clips, "temp/merged.mp4")
    // 5. 加片头
    fmt.Println("添加片头...")
    intro := createIntro(5)
    concatVideos([]string{intro, "temp/merged.mp4"}, "temp/with_intro.mp4")
    // 6. 加背景音乐
    fmt.Println("添加背景音乐...")
    addBGM("temp/with_intro.mp4", "materials/bgm/song.mp3", "output/baby_year.mp4")
    // 7. 清理临时文件
    os.RemoveAll("temp")
    fmt.Println("完成!视频保存在 output/baby_year.mp4")
}

还能怎么玩(如果你想)

上面的版本已经够用了,但你想让它更特别一点,可以再加几个功能:

  • 每日一句话的文本叠加:在每张照片上叠加“第X天:宝宝今天学会了...”的文字,用 drawtext 滤镜
  • 随机转场效果:在每两个片段之间加0.5秒的淡入淡出,用 xfade 滤镜
  • 生日气泡特效:在视频的特定时间点(比如满月、百天、周岁)弹出庆祝图标

这些都可以用ffmpeg加滤镜实现,Go只是帮你把它们串起来。

比如文字叠加:

ffmpeg.Input(imagePath).
    Filter("drawtext", ffmpeg.Args{
        "text":     fmt.Sprintf("第%d天 - %s", day, note),
        "fontfile": "/System/Library/Fonts/PingFang.ttc",
        "fontsize": "36",
        "fontcolor": "white",
        "x":        "(w-text_w)/2",
        "y":        "h-th-50",
    }).
    ...

好了,这个项目从想法到实现,大概花了我一个周末的时间,真正写代码可能只用了半天,剩下的时间全在整理素材和调试,你如果有兴趣,可以从“给娃做一本成长视频”这个点出发,把Golang当工具用,你会发现它比想象中顺手得多。

用Golang打造365天一周岁视频,从零到一的完整指南