用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天一周岁视频,本质上就是:
- 每天一张照片(或者视频片段)
- 每天一句语音/文字(第X天,宝宝会翻身了”)
- 按时间顺序拼接成一个视频
- 加个片头、片尾、背景音乐
我用的是这个目录结构:
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文件。
第四步:合并视频——别用concat用concat
合并视频最简单的方式是直接用 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当工具用,你会发现它比想象中顺手得多。
