你有没有想过,如果每天给自己录一段15秒的视频,坚持一年,最后拼成一条完整的365天视频,那会是什么感觉?我试过,说实话,前30天你会觉得好玩,第100天开始怀疑人生,第200天纯粹靠习惯硬撑,但到了第365天,当所有片段串起来那一刻,你整个人会起鸡皮疙瘩。
问题来了——手机相册里躺着几百段视频,怎么按日期排序?怎么拼接?怎么添加日期水印?手动处理?开什么玩笑,那得花一整天,这时候,Go语言就是你的最佳帮手,我花了大概三个晚上,用Go写了个小工具,把整个流程自动化了,今天就把这套思路完整拆给你看。
为什么是Go而不是Python或FFmpeg脚本?
先别急,我知道你会问,Python写脚本确实快,但有个致命伤:处理视频的依赖库装起来能把你逼疯,FFmpeg命令行虽然强大,但你要处理的是“按日期组织文件→批量处理→生成缩略图→拼接”这一整个流程,纯靠命令行脚本会让你的shell代码丑得没法看。
Go的好处在于:
- 编译成单个二进制文件,扔到服务器或者NAS上就能跑
- 标准库的
os和path/filepath处理文件遍历极其顺手 - 配合
github.com/u2takey/ffmpeg-go这个库,调用FFmpeg的API像写Go代码一样自然 - 并发处理视频片段的时候,goroutine比Python的多线程靠谱多了
我自己的使用场景是这样的:每天用手机录一段当天最有代表性的画面,然后通过SyncThing自动同步到家里的NAS,Go程序每天凌晨检查新文件,自动处理,整个过程不用我碰任何命令。
核心设计:三阶段管道
我的处理逻辑其实很简单,拆成三个阶段,用Go的管道模式串起来:
type VideoProcessor struct {
inputDir string
outputDir string
datePattern string // "2006-01-02"
}
扫描与排序
这个阶段负责把输入目录里所有视频文件找出来,按文件名里的日期排序,这里我踩过一个坑——手机导出的视频文件名可能不带日期,所以你得从文件的修改时间(ModTime)或者EXIF信息里提取。
func (vp *VideoProcessor) scanAndSort() ([]VideoFile, error) {
var videos []VideoFile
err := filepath.Walk(vp.inputDir, func(path string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() {
return err
}
ext := strings.ToLower(filepath.Ext(path))
if ext == ".mp4" || ext == ".mov" {
modTime := info.ModTime()
videos = append(videos, VideoFile{
Path: path,
Date: modTime.Format("2006-01-02"),
})
}
return nil
})
// 按日期排序
sort.Slice(videos, func(i, j int) bool {
return videos[i].Date < videos[j].Date
})
return videos, nil
}
这里有个细节值得注意:排序不仅仅是字典序,你得保证日期是标准的YYYY-MM-DD格式,不然2023-12-31会排在2024-01-01前面。
批量标准化处理
这个阶段的目标是把所有视频统一成相同的分辨率、帧率和编码格式,为什么?因为手机录像有时候竖屏有时候横屏,分辨率也千奇百怪,如果不统一,最后拼接出来的视频画面会疯狂跳变。

我用的是ffmpeg-go库,代码看起来这样:
func (vp *VideoProcessor) normalize(video VideoFile) error {
err := ffmpeg.Input(video.Path).
Output(vp.outputDir+"/normalized/"+video.Date+".mp4",
ffmpeg.KwArgs{
"vf": "scale=1080:1920:force_original_aspect_ratio=decrease,pad=1080:1920:(ow-iw)/2:(oh-ih)/2",
"r": "30",
"c:v": "libx264",
"preset": "medium",
"crf": "23",
}).
OverwriteOutput().
ErrorToStdOut().
Run()
return err
}
这个vf参数的意思是:把视频缩放后居中放在1080x1920的黑色背景上,因为我的视频都是竖屏拍的,统一成竖屏尺寸,到时候拼接就顺滑了。
拼接与时间轴
最后一个阶段是拼接,这里有两种思路:简单拼接和叠加时间轴,简单拼接就是concat demuxer,把文件列表写进文本文件然后交给FFmpeg,但更有意思的做法是给每个视频片段加上日期水印,并且在切换时用Xfade转场。
我最初用的是简单拼接,代码巨短:
func concatVideos(videoList []string, outputPath string) error {
listFile, _ := os.Create("concat_list.txt")
defer listFile.Close()
for _, v := range videoList {
fmt.Fprintf(listFile, "file '%s'\n", v)
}
err := ffmpeg.Input("concat_list.txt", ffmpeg.KwArgs{"f": "concat", "safe": "0"}).
Output(outputPath, ffmpeg.KwArgs{"c": "copy"}).
OverwriteOutput().Run()
return err
}
但后来觉得每天切换太生硬,就改成了用xfade滤镜,每个片段0.5秒交叉溶解,代码会复杂一点,但效果完全不一样——看着一年的时间在眼前淡入淡出,那感觉真的挺奇妙的。
额外的优化:加载提示与并发处理
一年365个视频,如果一个个按顺序处理,在普通电脑上得跑几个小时,我用Go的并发模型做了个优化:每个视频的标准化过程丢进goroutine,最多同时跑4个。
sem := make(chan struct{}, 4)
var wg sync.WaitGroup
for _, v := range videos {
wg.Add(1)
go func(video VideoFile) {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
vp.normalize(video)
}(v)
}
wg.Wait()
但记住一点:拼接阶段千万不能并发,FFmpeg的concat操作对文件顺序有强依赖,并发会出乱子。
另一个实用功能是进度反馈,处理第180天的时候你就知道“到底还剩多少个”有多重要了,我用了个简单的计数器加原子操作:
var processed int32
// 在每个goroutine里
atomic.AddInt32(&processed, 1)
fmt.Printf("进度: %d/%d, 完成率 %.1f%%\n",
atomic.LoadInt32(&processed), len(videos),
float64(atomic.LoadInt32(&processed))/float64(len(videos))*100)
一个意外的收获:缩略图日历
写着写着,我灵机一动:不如顺便生成一个缩略图日历?把每天的视频抽出一帧,拼成一个像日历一样的格子——1月的格子排在左上角,12月排在右下角,这样一眼就能看到这一年里每个时间段的生活色彩。
实现上用ffmpeg的select滤镜每N帧抽一帧,然后用Go的image库拼图:
func generateThumbnailCalendar(videos []VideoFile) error {
// 为每个视频生成一张缩略图
for i, video := range videos {
thumbsPath := fmt.Sprintf("thumbs/%d.jpg", i)
ffmpeg.Input(video.Path).
Output(thumbsPath, ffmpeg.KwArgs{
"vframes": "1",
"ss": "5", // 取第5秒的帧
}).
Run()
}
// 然后按7列(周)来排列,每个月换行...
// 代码较长,不贴了,核心逻辑就是往image.RGBA里画矩形
}
打印出来贴墙上,那种成就感不亚于看到最后成片的那一刻。
别忘了音频的处理
第一版的时候我偷懒,拼接时没管音频,结果发现画面是顺畅了,但背景音乐断断续续的,后来我加了音轨处理:先对每个片段的音频做归一化(loudnorm滤镜),然后把所有音轨静音,只保留环境底噪的混合。
怎么弄的?在拼接之前先单独处理音频:
ffmpeg.Input(video.Path).
Output("audio_"+video.Date+".wav", ffmpeg.KwArgs{
"vn": "", // 忽略视频流
"af": "loudnorm=I=-16:TP=-1.5:LRA=11",
}).
Run()
最后用amerge或amix把整年的音频混成一条背景音轨,不过说实话,我最后成品视频是直接把音频全部静音,配上自己选的BGM,一年的环境音混在一起真的不好听。
关于文件命名和日期提取,我最后用了更稳妥的做法
别依赖文件修改时间——因为同步软件可能会改变ModTime,我后来改成优先从文件名里正则提取日期,实在提取不到才用ModTime:
func extractDate(filename string, modTime time.Time) string {
re := regexp.MustCompile(`(\d{4})[-_]?(\d{2})[-_]?(\d{2})`)
matches := re.FindStringSubmatch(filename)
if len(matches) == 4 {
return fmt.Sprintf("%s-%s-%s", matches[1], matches[2], matches[3])
}
return modTime.Format("2006-01-02")
}
这样做的原因是:我有个习惯是偶尔会补录,比如某天忘记录了,第二天早上补一段,如果按修改时间排序,补录的视频会排到第二天后面,但那天的位置就空着了,所以文件名里写清楚日期是最稳的。
代码仓库里的其他小工具
写这个主程序的同时,我还顺手写了两个小工具:
check_drop:检查哪些日期缺失视频,比如发现3月17日没录像,提醒你补拍。make_gif:把365段视频每隔10天抽一帧,做成一个动态GIF,方便发朋友圈。
这些小东西都很简单,加起来不到500行Go代码,但真的可以养成一个好习惯。
最后想说的是...
这套程序我跑了两个多月了,目前进度到第127天,说实话,最有意思的不是最后拼出来的成品,而是每天按下录制键之前想“今天有什么值得记录的”的那个瞬间,Go这个语言不煽情,但它写出来的工具能让你坚持在做一件煽情的事。
代码一点都不完美——我甚至没写单元测试,错误处理也粗暴,但有什么关系呢?它跑起来稳稳的,处理完视频第二天早上默默地把成品放在NAS的/record/365days目录下,你只需要打开那个文件夹,看一天天累积起来的文件大小从几百MB慢慢变成几个GB,那种充实的重量,比任何社交媒体的点赞都真实。
哦对了,如果你也打算做这件事,记得把代码和视频目录都备份两份,硬盘会坏,但数据不会骗你。
本文来自作者[kyadmin]投稿,不代表be365立场,如若转载,请注明出处:http://hljbesthome.com/jiankang/2559.html
评论列表(4条)
我是be365的签约作者“kyadmin”!
希望本篇文章《从第一天到第365天,用Go语言打造你的专属成长视频记录系统》能对你有所帮助!
本站[be365]内容主要涵盖:be365,be365网站,be365网址,ball365体育,Be365排名
本文概览:你有没有想过,如果每天给自己录一段15秒的视频,坚持一年,最后拼成一条完整的365天视频,那会是什么感觉?我试过,说实话,前30天你会觉...