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

一周岁365天视频模版,用Golang写个工具,把娃的第一次都存下来

  • 房产
  • 2026-07-04 08:17:29
  • 294
摘要: 为啥要自己写这个视频模版?市面上不是有很多现成的吗?说实话,我家娃过周岁那会儿,我也买了某宝上的视频模版,30来块钱,挺好看的,...

为啥要自己写这个视频模版?市面上不是有很多现成的吗?

说实话,我家娃过周岁那会儿,我也买了某宝上的视频模版,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_starttrim_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)。

让视频更有温度的几个小技巧

纯技术说完了,聊点感性的东西,我做了好几个版本给家里看,最后发现下面这几个细节最打动人:

  1. 在倒计时数字旁边放一展示当前月份的照片,比如第30天配满月照,第100天配百日照,这需要你在JSON里把场景排好顺序,让照片和倒计时交替出现。

  2. 片头留3秒黑屏,只显示“致我们的小豆包”几个字,这个用FFmpeg的drawtext就可以,字体用楷体,慢慢淡入,那个效果第一次放出来的时候,我妈直接哭了。

  3. 多放娃笑和哭的声音,比纯音乐有感染力,我在视频类型里加了保留原声的选项:

if scene.KeepAudio {
    args = append(args, "-map", "0:a:0")
}
  1. 最后一张照片定住5秒,不要 fade out 得太快,毕竟365天就这么结束了,让人多看看。

完整跑一次是啥感觉?

周末下午,我把所有的素材整理好——手机里的照片拷出来按日期排好,视频剪出精片段,选了一首娃平时一听就笑的《小星星》钢琴曲当BGM,然后在终端里执行:

go run . -config 小豆包素材.json

程序跑了大概两分钟(我那台2019年的MacBook Pro),中间打印出“正在处理场景3/12...正在处理场景4/12...”这样的日志,两分钟后,output.mp4出现了。

点开一看——第一个镜头是她出生两天,在医院里皱巴巴的小脸,第二个镜头是满月酒时她戴着小金锁,第三个镜头是她第一次打疫苗哭得撕心裂肺……然后倒计时飞速跳转,照片一张一张闪过去:翻身、爬行、扶着沙发站起来、迈出第一步……到结尾定格在一张全家福上,她戴着生日帽,笑得露出四颗牙。

我老婆在旁边看着看着就抹眼泪了,说这比外面做的强一百倍。

其实技术上没啥特别牛的地方,但每个画面都是我们自己选的,每个字幕都是我们自己写的,连代码里处理照片缩放的那个算法,都是为我们家那堆乱七八糟分辨率的照片定制的,这就是自己动手做一周岁视频模版的意义吧。

你要不要也试一下?代码写起来不难,真正难的是挑素材的时候——你会发现自己坐在电脑前,翻着过去一年拍的照片,不知不觉就笑出声,然后又莫名其妙地红了眼眶。

一周岁365天视频模版,用Golang写个工具,把娃的第一次都存下来