一周岁365天视频模版,用Golang写个工具,把娃的第一次都存下来
- 房产
- 2026-07-04 08:17:29
- 294
为啥要自己写这个视频模版?市面上不是有很多现成的吗?
说实话,我家娃过周岁那会儿,我也买了某宝上的视频模版,30来块钱,挺好看的。—那个模版里面全是别人家孩子的照片占位,我得一张一张替换,还限定了视频帧数,想多加几张都不行,更烦的是,那音乐配得老土,我家娃明明更爱听周杰伦的《听妈妈的话》嘛。
后来我就想,我好歹是个写Go的程序员啊,干嘛不自己搞一个?
于是我就动手写了个一周岁365天视频模版的Golang工具,你别一听“自己写代码”就怕,其实弄完了特别有成就感,今天我把这个思路和代码结构摊开来聊,你照着也能整一个。
365天,到底要放哪些素材?
先别急着写代码,得想清楚这个视频模版里要装啥,我把自家娃的素材分了这几个类:
| 素材类型 | 数量建议 | 说明 |
|---|---|---|
| 出生当天照片 | 1-2张 | 刚出生的样子,越模糊越珍贵 |
| 每月一张大头照 | 12张 | 每个月同一天拍的,看变化 |
| 里程碑视频片段 | 5-10个短片段 | 第一次翻身、坐、爬、站、走 |
| 生活日常片段 | 10-20个 | 吃辅食、洗澡、傻笑、哭鼻子 |
| 家人互动 | 5-8个 | 跟爸爸妈妈爷爷奶奶一起的镜头 |
| 365天倒计时动画 | 自动生成 | 用代码从1数到365 |
我一开始贪心,想塞500张照片进去,结果输出视频又长又沉闷。后来发现15-20秒以内最好,重点是要有“变化感”——比如每月同一天拍的大头照拼在一起,那头发从光头到小刺猬到小卷毛的过程,看着就想哭。
用Golang搭个视频生成脚手架
好,开始写代码,这里我先说最核心的思路——Go语言操作FFmpeg,你电脑上得有FFmpeg,没有的话先装一下。(Mac用brew install ffmpeg,Windows去官网下个安装包)。
第一步:整理素材的JSON配置文件
我不想每次改素材都在代码里翻来翻去,所以写了个JSON来管所有素材,比如素材.json:
{
"project_name": "小豆包的一周岁",
"output_path": "./output.mp4",
"duration_per_photo": 2.0,
"bgm": "./music/tingmamadehua.mp3",
"scenes": [
{
"type": "photo",
"path": "./photos/birth.jpg",
"caption": "2023年3月15日 07:32 出生",
"duration": 3.0,
"transition": "fade"
},
{
"type": "video",
"path": "./videos/first-sit.mp4",
"caption": "第187天 终于坐起来了!",
"duration": 5.0,
"transition": "none"
},
{
"type": "countdown",
"start_day": 1,
"end_day": 365,
"duration": 8.0,
"style": "handwrite"
}
]
}
这JSON的结构很简单对吧?关键就是类型(type)——照片、视频片段、倒计时动画,每种类型有不同的参数。
第二步:用Golang解析JSON并调用FFmpeg
这是代码的核心部分,我用的是Go标准库里的os/exec包来调FFmpeg,不用装任何第三方依赖。
先写个结构体来映射上面的JSON:
type Project struct {
Name string `json:"project_name"`
OutputPath string `json:"output_path"`
DurationPerPhoto float64 `json:"duration_per_photo"`
BGM string `json:"bgm"`
Scenes []Scene `json:"scenes"`
}
type Scene struct {
Type string `json:"type"` // "photo", "video", "countdown"
Path string `json:"path"`
Caption string `json:"caption"`
Duration float64 `json:"duration"`
Transition string `json:"transition"`
StartDay int `json:"start_day"` // countdown用
EndDay int `json:"end_day"` // countdown用
Style string `json:"style"` // countdown用
}
然后读JSON文件,一行代码的事儿:
func LoadProject(configPath string) (*Project, error) {
data, err := os.ReadFile(configPath)
if err != nil {
return nil, err
}
var proj Project
if err := json.Unmarshal(data, &proj); err != nil {
return nil, err
}
return &proj, nil
}
接下来就是最烧脑但也最好玩的部分——构造FFmpeg命令,我得把每个场景转成FFmpeg能懂的语言。
比如一个照片场景,我想让它显示2秒,带淡入淡出效果,再加个字幕在底部,FFmpeg命令长这样(如果不熟悉FFmpeg也别怕,代码帮你拼好了):
ffmpeg -loop 1 -i photo.jpg -vf "fade=t=in:st=0:d=0.5,fade=t=out:st=1.5:d=0.5,drawtext=text='第187天 终于坐起来了!':x=(w-text_w)/2:y=h-50:fontsize=24:fontcolor=white" -t 2 -c:v libx264 temp_0.mp4
用Go来拼这个命令:
func buildPhotoCommand(scene Scene, index int) string {
fadeIn := fmt.Sprintf("fade=t=in:st=0:d=0.5")
fadeOut := fmt.Sprintf("fade=t=out:st=%.1f:d=0.5", scene.Duration-0.5)
text := fmt.Sprintf("drawtext=text='%s':x=(w-text_w)/2:y=h-50:fontsize=24:fontcolor=white", scene.Caption)
vf := fmt.Sprintf("%s,%s,%s", fadeIn, fadeOut, text)
output := fmt.Sprintf("temp_%d.mp4", index)
// 实际用exec.Command调用
return fmt.Sprintf("ffmpeg -loop 1 -i %s -vf \"%s\" -t %.2f -c:v libx264 %s",
scene.Path, vf, scene.Duration, output)
}
倒计时场景更酷,我想让视频上面显示天数从1到365,每个数字出现大约0.02秒(8秒内走完365天,差不多就是电影那种刷刷过的感觉),FFmpeg的drawtext可以用表达式来动态变化:
drawtext=text='第 %{eif\\:trunc((t/8)*365+1)\\:d} 天':x=(w-text_w)/2:y=h/2-50:fontsize=60:fontcolor=yellow
在Go里,这行表达式得用转义符包起来,不然Go的字符串会搞混。
func buildCountdownCommand(scene Scene, index int) string {
// 注意:FFmpeg的表达式里大括号跟Go的模板冲突,要用双反斜杠
textExpr := `text='第 %{eif\:trunc((t/` +
fmt.Sprintf("%.2f", scene.Duration) +
`)*` + fmt.Sprintf("%d", scene.EndDay-scene.StartDay+1) +
`+` + fmt.Sprintf("%d", scene.StartDay) +
`)\:d} 天'`
vf := fmt.Sprintf("drawtext=%s:x=(w-text_w)/2:y=h/2-50:fontsize=60:fontcolor=yellow", textExpr)
output := fmt.Sprintf("temp_%d.mp4", index)
return fmt.Sprintf("ffmpeg -f lavfi -i color=c=black:s=1920x1080:d=%.2f -vf \"%s\" -c:v libx264 %s",
scene.Duration, vf, output)
}
第三步:把零碎片段拼接起来
所有场景都转成独立的临时MP4了,最后一步——合并,FFmpeg合并视频片段有两种方式,我选最稳的concat demuxer:
先写个文本文件(比如files.txt),里面每行写一个file temp_0.mp4,然后这么跑:
func concatVideos(parts []string, outputPath string, bgmPath string) error {
// 生成files.txt
var buf bytes.Buffer
for i := range parts {
buf.WriteString(fmt.Sprintf("file 'temp_%d.mp4'\n", i))
}
filesList := "./files.txt"
os.WriteFile(filesList, buf.Bytes(), 0644)
// 拼接无音频的视频
cmd := exec.Command("ffmpeg", "-f", "concat", "-safe", "0",
"-i", filesList, "-c", "copy", "-an", "temp_concat.mp4")
if err := cmd.Run(); err != nil {
return err
}
// 加上背景音乐
cmd = exec.Command("ffmpeg", "-i", "temp_concat.mp4", "-i", bgmPath,
"-c:v", "copy", "-c:a", "aac", "-map", "0:v:0", "-map", "1:a:0",
"-shortest", outputPath)
if err := cmd.Run(); err != nil {
return err
}
// 清理临时文件
os.Remove("temp_concat.mp4")
for i := range parts {
os.Remove(fmt.Sprintf("temp_%d.mp4", i))
}
os.Remove(filesList)
return nil
}
你发现没,我没用Go里那些复杂的视频编辑库。FFmpeg本身已经够强大了,Go在这里的角色更像是一个“指挥官”——读配置、排流程、调FFmpeg干活,这种组合拳写起来干净、调试也直接。
遇到的实际坑和怎么爬出来的
写这个工具不是一帆风顺的,说说我踩过的坑:
坑1:照片分辨率不一致
我家娃的照片有的是手机横拍的1920x1080,有的是爷爷奶奶用旧手机竖拍的720x1280,放一起画面就被压扁了,解决方案是在每个照片场景前加scale=1920:1080:force_original_aspect_ratio=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2:color=black——先等比缩放,再用黑边填充空白。
func addScaleAndPad(vf string, targetW, targetH int) string {
scalePad := fmt.Sprintf("scale=%d:%d:force_original_aspect_ratio=1,pad=%d:%d:(ow-iw)/2:(oh-ih)/2:color=black",
targetW, targetH, targetW, targetH)
if vf == "" {
return scalePad
}
return scalePad + "," + vf
}
坑2:视频片段太长
老公拍的娃第一次走路,拍了两分钟!但我要的是5秒精华,所以我在JSON里给视频类型加了trim_start和trim_duration参数:
if scene.TrimStart > 0 || scene.TrimDuration > 0 {
ffmpegArgs = append(ffmpegArgs, "-ss", fmt.Sprintf("%.2f", scene.TrimStart))
if scene.TrimDuration > 0 {
ffmpegArgs = append(ffmpegArgs, "-t", fmt.Sprintf("%.2f", scene.TrimDuration))
}
}
坑3:中文字符乱码
drawtext默认字体不支持中文,字幕显示成方块,得指定一个中文字体文件路径,我在配置文件里加了个font_path参数,比如/System/Library/Fonts/PingFang.ttc(Mac)或者C:/Windows/Fonts/msyh.ttc(Windows)。
让视频更有温度的几个小技巧
纯技术说完了,聊点感性的东西,我做了好几个版本给家里看,最后发现下面这几个细节最打动人:
-
在倒计时数字旁边放一展示当前月份的照片,比如第30天配满月照,第100天配百日照,这需要你在JSON里把场景排好顺序,让照片和倒计时交替出现。
-
片头留3秒黑屏,只显示“致我们的小豆包”几个字,这个用FFmpeg的
drawtext就可以,字体用楷体,慢慢淡入,那个效果第一次放出来的时候,我妈直接哭了。 -
多放娃笑和哭的声音,比纯音乐有感染力,我在视频类型里加了保留原声的选项:
if scene.KeepAudio {
args = append(args, "-map", "0:a:0")
}
- 最后一张照片定住5秒,不要 fade out 得太快,毕竟365天就这么结束了,让人多看看。
完整跑一次是啥感觉?
周末下午,我把所有的素材整理好——手机里的照片拷出来按日期排好,视频剪出精片段,选了一首娃平时一听就笑的《小星星》钢琴曲当BGM,然后在终端里执行:
go run . -config 小豆包素材.json
程序跑了大概两分钟(我那台2019年的MacBook Pro),中间打印出“正在处理场景3/12...正在处理场景4/12...”这样的日志,两分钟后,output.mp4出现了。
点开一看——第一个镜头是她出生两天,在医院里皱巴巴的小脸,第二个镜头是满月酒时她戴着小金锁,第三个镜头是她第一次打疫苗哭得撕心裂肺……然后倒计时飞速跳转,照片一张一张闪过去:翻身、爬行、扶着沙发站起来、迈出第一步……到结尾定格在一张全家福上,她戴着生日帽,笑得露出四颗牙。
我老婆在旁边看着看着就抹眼泪了,说这比外面做的强一百倍。
其实技术上没啥特别牛的地方,但每个画面都是我们自己选的,每个字幕都是我们自己写的,连代码里处理照片缩放的那个算法,都是为我们家那堆乱七八糟分辨率的照片定制的,这就是自己动手做一周岁视频模版的意义吧。
你要不要也试一下?代码写起来不难,真正难的是挑素材的时候——你会发现自己坐在电脑前,翻着过去一年拍的照片,不知不觉就笑出声,然后又莫名其妙地红了眼眶。
