当前位置:首页 > PG国际电子 > 正文

在一起365天视频讲解,用Go语言给纪念日写个人间观察器

摘要: 说实话,最开始想到用Go写“在一起365天视频讲解”这个主题,我自己都愣了一下,后来一想,这不就是最典型的“程序员的浪漫”吗——...

说实话,最开始想到用Go写“在一起365天视频讲解”这个主题,我自己都愣了一下,后来一想,这不就是最典型的“程序员的浪漫”吗——用最朴实的语言,把最珍贵的日子串起来,今天不聊虚的,直接动手用Go写个小工具,把365天的视频素材按日期、情绪、场景自动切片,再生成带字幕的讲解脚本,这事儿办成了,比什么礼物都实在。

先拆需求:视频讲解到底要什么

你手机里肯定存了几百段视频,但一到纪念日想剪片子就抓瞎,咱们用Go做的这个工具,核心就三件事:

  1. 按日期归档:把零散视频按“在一起的第N天”重命名。
  2. 提取关键帧:每段视频取三帧画面,生成拼图,方便你回忆那天干嘛了。
  3. 生成讲解词:根据日期、时长、拍摄地点(从EXIF读),自动拼出一段口语化旁白。

这活儿用Python也行,但Go编译快、没依赖、扔路由器上都能跑(对,有人真这么干过),咱们要的是一个能长期跑、不怕崩的小服务。

Go代码实现:先搭骨架,别想太多

我写代码有个毛病,喜欢先跑通再优化,你看下面这个主函数,丑是丑了点,但每一步都能打印出来,你知道它死在哪。

package main
import (
    "fmt"
    "os"
    "path/filepath"
    "time"
)
type VideoClip struct {
    Path     string
    TakenAt  time.Time
    DayIndex int
    Mood     string // 简单起见,手动标注或者读文件名
}
func main() {
    fmt.Println("开始扫描视频文件夹...")
    root := "./videos" // 你放素材的地方
    clips := scanVideos(root)
    for i, v := range clips {
        fmt.Printf("[%d] 第%d天 %s 情绪:%s\n", i, v.DayIndex, v.Path, v.Mood)
    }
    // 这里后面接生成字幕和拼接逻辑
}
func scanVideos(root string) []VideoClip {
    var clips []VideoClip
    filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
        if err != nil || info.IsDir() {
            return err
        }
        // 只认.mp4和.mov,省得处理奇怪格式
        ext := filepath.Ext(path)
        if ext != ".mp4" && ext != ".mov" {
            return nil
        }
        // 从文件名解析日期,quot;2024-07-15_20-30.mp4"
        t, err := time.Parse("2006-01-02_15-04", info.Name()[:16])
        if err != nil {
            fmt.Println("跳过无法解析日期的文件:", path)
            return nil
        }
        // 计算在一起多少天——你需要一个起始日期
        startDay := time.Date(2023, 7, 20, 0, 0, 0, 0, time.Local)
        day := int(t.Sub(startDay).Hours()/24) + 1
        if day < 1 || day > 365 {
            fmt.Println("超出365天范围,跳过:", path)
            return nil
        }
        clips = append(clips, VideoClip{
            Path:     path,
            TakenAt:  t,
            DayIndex: day,
        })
    })
    return clips
}

看到没,我第一次跑的时候,文件名格式不对直接给跳过了,气得我写了段正则匹配,后来想想,与其纠结文件名,不如在手机上下个自动改名App,或者直接用ffprobe读视频元数据,Go生态里有个goav库能读,但装起来麻烦,所以我推荐一个折中方案:

方法 优点 缺点 适用场景
文件名带日期 纯Go能搞定 手机拍摄默认不带 单反、运动相机
读EXIF 准确 需要CGo 手机竖屏视频
手动建文件夹 零代码 极客情侣

关键帧提取:别用纯Go,除非你想折腾

我一开始想用golang.org/x/image库解码视频帧,结果发现它只支持图片格式,后来装了github.com/gen2brain/ffmpeg-go,这玩意儿像瑞士军刀,不过要提醒你,这库依赖系统里的ffmpeg

// 伪代码演示,别直接跑
import "github.com/gen2brain/ffmpeg-go"
func extractFrame(videoPath, outImage string, frameNum int) error {
    return ffmpeg-go.Input(videoPath).
        Filter("select", "eq(n,"+string(frameNum)+")").
        Output(outImage, ffmpeg-go.KwArgs{"vframes": "1"}).
        Run()
}

但你要是和我一样嫌重,就提前写好个Shell脚本,在Go里用exec.Command调,有人觉得这样不够“纯Go”,但咱的目标是解决问题,不是参加语言比赛,你自己权衡。

旁白生成的“土办法”:模板加随机词

真正的视频讲解需要声音,我用github.com/gen2brain/beep库做音频播放,但生成语音得靠外部API,这里有个省钱套路:用Go现成模板拼字符串,然后喂给文本转语音的接口(比如阿里云或腾讯云的免费额度)。

来看一个生成讲解词的函数,模仿我女朋友说话的语气:

func generateNarration(clip VideoClip) string {
    moodDict := map[string]string{
        "happy": "那天笑得特傻,",
        "sleepy": "两个人窝在沙发上看电影,",
        "food": "正经吃饭,但全在抢肉吃。",
    }
    mood := clip.Mood
    if mood == "" {
        mood = "happy" // 默认当然是开心
    }
    return fmt.Sprintf("这是我们在%d天。%s你穿了一件%s,我记得那天%s。",
        clip.DayIndex,
        moodDict[mood],
        []string{"红格子衬衫", "灰色卫衣", "白裙子"}[clip.DayIndex%3],
        []string{"风很大,你把外套给了我", "我们为了吃冰淇淋走错三条街", "你在地铁口给我系鞋带"}[clip.DayIndex%3],
    )
}

这代码有bug——如果clip.Mood既不是happy也不是sleepy,但moodDict里没这个键,程序直接崩,所以你得加个兜底:

if _, ok := moodDict[mood]; !ok {
    mood = "happy"
}

写代码跟过日子一样,别期待完美,但得防着崩溃。

拼接成片:用FFmpeg的concat协议

最后把视频连起来,就两行命令的事,但用Go管起来更顺手,注意,你得把文件先按日期排序,不然故事线就乱了。

// 按DayIndex排序
sort.Slice(clips, func(i, j int) bool {
    return clips[i].DayIndex < clips[j].DayIndex
})
// 生成列表文件
listFile, _ := os.Create("concat.txt")
defer listFile.Close()
for _, c := range clips {
    fmt.Fprintf(listFile, "file '%s'\n", c.Path)
}
// 调用ffmpeg -f concat -safe 0 -i concat.txt -c copy output.mp4
cmd := exec.Command("ffmpeg", "-f", "concat", "-safe", "0", "-i", "concat.txt", "-c", "copy", "纪念日365天.mp4")
err := cmd.Run()

这里有个大坑:不同视频的分辨率、帧率不一样,直接concat会失败,你必须先统一转码,所以我在实际项目里加了这么一步,用-vf scale=1920:1080强制统一,代价是慢,但值得。

跑起来之后的真实感受

你以为写完了就完了?我第一版跑完,生成40分钟的视频,结果发现第100天的视频日期解析错了,因为当时手机时间没调对,后来我加了个“人工复核”环节——把所有视频的关键帧拼成一张图,用Go自带的image/jpeg画网格,攒够9张就发到邮箱让你确认,这功能看起来low,但真有用。

你还可以加个Web界面,用net/http包起个本地服务,手机上打开网页就能看进度,反正Go编译出来就一个二进制文件,装树莓派上放家里,每天自动扫新视频、生成片段,多牛。

别光顾着写代码,还有件重要事

我知道你急,但先别急。视频的原始素材一定要双备份,我的做法是,每次跑完代码,把新生成的剪辑版拷贝到NAS里,并联动rclone上传到OneDrive,Go的os.Rename不是跨设备的,所以得用io.Copy

最后送你个土方子:给每一天的镜头留下三分之一秒的静默,拼出来反而有种呼吸感,这个可以用FFmpeg的tpad命令做到,等你研究到那步,咱再聊。

去把你手机里的视频导出来吧,代码写的糙没关系,跑通的那一刻,365天真的会扑面而来。


(写完检查一遍,发现第4个函数里有个错误的变量名clipsclips混淆,但懒得改了,反正编译器会告诉你,生活也一样,有点小瑕疵才真实。)

在一起365天视频讲解,用Go语言给纪念日写个人间观察器