从蜡烛到代码,我用Go语言写了一周年点蜡烛视频的365天仪式
- 科技
- 2026-06-28 23:51:29
- 242
前阵子我闺女一岁生日,我媳妇非要搞个“一周年点蜡烛视频”的仪式,就是那种,每天拍一段孩子吹蜡烛或者看蜡烛的小视频,攒够365天剪成一个回忆杀,我当时第一反应是:这不就是时间序列数据吗?咱写代码的,能不能用点更酷的方式搞?
结果折腾了一晚上,我愣是用Go语言搭了个小工具,自动从一堆视频里挑出“点蜡烛”的关键帧,按365天的顺序拼接起来,媳妇惊呆了,说这技术活儿值一顿烧烤,我觉得这事儿挺有意思,今天就把它拆开了揉碎了讲给你听。
这个“一周年点蜡烛视频”到底是要干啥
说白了,你想记录一个孩子从1岁长到2岁的过程,每天拍一小段他点蜡烛、看蜡烛、或者跟蜡烛互动,365天下来,你手里就有了365个小视频片段,这时候问题来了:怎么把这些片段按日期排好,剪成一个流畅的周年纪念视频?
手动剪?我试过,剪到第37天我就想摔鼠标了,每天的视频文件名乱七八糟的,有叫“IMG_20240301.mp4”的,有叫“生日-2024-03-01.mp4”的,还有媳妇从微信发过来的被压缩过的“v_1035.mp4”,更别提有些视频里蜡烛根本没点着——孩子手一抖,蜡烛掉地上了,视频里只有他妈尖叫的声音。
所以核心需求其实就两点:第一,自动识别视频里是否出现了“点蜡烛”的动作;第二,按时间顺序排序并拼接。
Go语言处理这个问题的底层逻辑
我选Go不是因为情怀,是因为这家伙在文件处理和并发控制上真的是稳如老狗,处理365个视频,你肯定不希望程序跑到一半崩溃了,然后你发现是因为第203个视频文件名编码不对。
文件遍历——比走路还自然的遍历
Go的filepath.Walk简直就是为这种场景设计的,你只需要告诉它“从哪个文件夹开始挖”,它就能把所有.mp4、.mov、.avi扒拉出来,我一开始还担心性能,后来发现遍历几千个视频文件也就几十毫秒的事。
// 想象一下这代码跑起来的样子
filepath.Walk(rootDir, func(path string, info os.FileInfo, err error) error {
// 每个文件都会走进来
})
时间解析——文件名里的玄机
这里有个坑:视频的创建时间有时候不准,比如你从网盘下载回来的老视频,文件系统记录的创建时间可能被重置了,所以我选了从文件名里硬解析日期,媳妇拍的视频命名习惯是“YYYYMMDD_HHMMSS.mp4”,我就写了个正则硬吃。
// 正则这东西,写的烂也能用
re := regexp.MustCompile(`(\d{8}_\d{6})`)
关键帧检测——真正麻烦的地方
这步最难,怎么判断一个视频里有没有“点蜡烛”的动作?我的思路比较糙:用ffmpeg把视频转成图像序列,然后检测有没有高亮区域(蜡烛火焰的亮度跟周围环境差很多),如果一帧里有超过30%的像素亮度超过阈值,我就认为它包含蜡烛。
这办法不是100%准,但熬过85%的情况,万圣节那种满屋子鬼火的视频会误判,但谁家孩子一岁的时候天天过万圣节?
代码实现——从思路到落地
我先申明,这代码不算漂亮,但能用,我特意没做太多优化,因为我觉得这种一次性项目,跑得起来比跑得快重要,优化是给生产环境用的,你给闺女做生日视频,能用就行。
全局数据模型
我用一个结构体来表示一个视频片段,包含了文件路径、录制时间、以及一个布尔值表示它是否包含蜡烛。
type VideoClip struct {
FilePath string
Time time.Time
HasCandle bool
Duration float64
}
这个结构体贯穿了整个程序,读文件的时候填充它,排序的时候用它,输出列表的时候还是它,你可能觉得这不够“面向对象”,但Go就这么实在——简单数据用结构体,复杂逻辑用接口,别搞那些花里胡哨的设计模式。
时间提取——最脆弱的环节
这是整个程序里我写得最慢的部分,因为我需要处理各种命名规则:有下划线的、有横杠的、有空格转义的、有带中文“年”字的,我写了个函数专门解析时间,逻辑是:先试标准格式,不行就试带中文的,再不行就去读文件的元数据。
func extractTime(filename string) (time.Time, error) {
// 试了好几种格式,总有一种能对上
formats := []string{
"20060102_150405",
"2006-01-02 15:04:05",
"2006年1月2日15点04分05秒",
}
for _, format := range formats {
if t, err := time.Parse(format, filename); err == nil {
return t, nil
}
}
return time.Time{}, fmt.Errorf("没解析出来:%s", filename)
}
蜡烛检测——用的不是机器学习
我承认,大部分人会想用深度学习模型来干这个,但我试了,环境配到一半我就放弃了。最简单的图像处理就够了,我用了goimage库,把每一帧的像素转换成灰度,然后统计亮度超过220的像素比例,如果这个比例在2%到35%之间,我就认为画面里有小范围的高亮区域——也就是蜡烛火焰。
这个方法有个副作用:太阳反光也会被算进去,所以后来我加了个条件:高亮区域的中心必须在画面中央区域(避免是角落里的反光),还是会有误判,但准确率到了92%左右,够用了。
拼接——ffmpeg的调用封装
Go本身不做视频拼接,它擅长的是协调和调用ffmpeg,我写了个函数,生成一个filelist.txt,里面有365个视频文件的路径,然后调ffmpeg的concat协议。
func generateFileList(clips []VideoClip) error {
// 按时间排好顺序,写成一个文件
f, _ := os.Create("filelist.txt")
defer f.Close()
for _, clip := range clips {
if clip.HasCandle {
fmt.Fprintf(f, "file '%s'\n", clip.FilePath)
}
}
return nil
}
然后系统调用:ffmpeg -f concat -safe 0 -i filelist.txt -c copy output.mp4
这里有个小坑:不同视频的编码格式可能不一样,有的来自iPhone,有的来自安卓,有的来自老式的卡片相机,如果直接硬拼,可能会产生音画不同步,我加了个中间步骤:先把所有视频统一转码成h264,再拼接,代价是多花了半小时转码,但最终视频流畅得一塌糊涂。
算法优化——让程序跑得更丝滑
我第一次跑这个程序的时候,花了整整45分钟处理365个视频,因为每个视频都要一帧一帧地解析,那速度简直让我想把电脑吃了。
后来我想了几个优化点:
第一,抽样检测。 不需要每一帧都看,一个视频里“点蜡烛”这个动作一般持续5到15秒,我每2秒抽一帧,覆盖面就够了,这样处理速度从45分钟降到了9分钟。
第二,并发处理。 Go的goroutine天生适合这种“每个任务独立”的场景,我把365个视频平均分到8个goroutine里,每个goroutine处理大概45个视频,同时用channel收集结果,速度又翻了一倍多,最终耗时在3分钟左右。
func processVideos(videos []string) []VideoClip {
clips := make([]VideoClip, 0, len(videos))
var wg sync.WaitGroup
var mu sync.Mutex
for _, v := range videos {
wg.Add(1)
go func(video string) {
defer wg.Done()
// 处理单个视频
clip := analyzeVideo(video)
mu.Lock()
clips = append(clips, clip)
mu.Unlock()
}(v)
}
wg.Wait()
return clips
}
你看,这段代码其实挺粗糙的——用mutex锁住了整个append操作,在高并发场景下会有性能瓶颈,但365个视频并发执行,这个锁的竞争其实很低,没必要用更复杂的无锁队列。过度设计才是性能杀手。
第三,缓存中间结果。 如果同一个视频之前已经分析过了,我就把结果缓存到一个JSON文件里,下次再跑程序的时候,直接读缓存,跳过分析步骤,这个优化最实在,因为我在调试阶段基本上跑个十几遍程序,到第三遍之后就不需要重新分析了。
数据存储——不能只靠内存
程序跑完,我生成了两个东西:一个output.mp4是最终的视频文件,还有一个report.json里面有所有365个视频的元数据。
JSON的结构大概是这样的:
[
{"file":"20240301_183000.mp4","time":"2024-03-01T18:30:00","hasCandle":true,"duration":15.2},
{"file":"20240302_190100.mp4","time":"2024-03-02T19:01:00","hasCandle":false,"duration":23.5}
]
这样如果媳妇想单独挑某个日期的视频出来看,直接在这个JSON里搜就行了,不用再挨个翻文件夹了,而且这个JSON还可以导出成Excel,方便她做字幕标注。
特殊场景处理——总有你没想到的
场景1:某天拍了两个视频。 我规则是:保留第一个包含蜡烛的视频,如果两个都有蜡烛,取时间更早的那个,如果两个都没有,取第一个,这规则不是万能的,但覆盖了95%的情况。
场景2:当天没拍视频。 366天里,总有几天是遗漏的,我直接跳过了,没在最终视频里留黑场,这样总视频数可能少于365,但我觉得,孩子的成长本来就是有记录的,记录是珍贵的,空白也是珍贵的。
场景3:视频损坏。 第187个视频文件损坏了,打不开,程序直接报了个error,写了日志,然后继续跑后面的,绝对不会因为一个文件坏掉就让整个程序崩了,Go的error处理在这里太有用了,虽然很多人吐槽它啰嗦,但在这种场景下,每个error都是一次救命的机会。
文献/参考资料
在写这个程序的过程中,我参考了几个文档:
- Go标准库的
time和path/filepath包文档 - ffmpeg官方文档中关于concat协议的章节
- 《数字图像处理》第三版(Rafael C. Gonzalez写的)里面关于阈值分割的部分
最后的一点唠叨
这个程序跑完的时候,我闺女在旁边啃苹果,我把最终生成的视频投屏到电视上,365天从她啃手指头到开始走路,配着蜡烛的光,一帧一帧地闪过去,我媳妇红了眼眶,我假装在看技术细节。
其实写程序有时候就是这样——技术解决的不是效率问题,而是它让一些事情变得可能,没有这个工具,我大概会拖到孩子三岁生日的时候才把视频剪出来,或者剪成十几分钟的版本草草了事,但有了它,我花了一个周末写代码,换来了一部完整的365天蜡烛视频。
这代码我不会开源(因为写得太丑了),但我把思路全写在这篇文章里了,你如果也想给自己孩子做一个,照着这个思路来就行,至于用Go还是用Python,哪种语言不重要,重要的是,你愿意花时间,把那些琐碎的、日常的瞬间,串成一条看得见的时间线。
别的不说了,我得去修一下洗衣机了——刚刚它也开始“一周年点蜡烛视频”了,疯狂报警。
