用Go语言写了个程序,把365天的视频拼成了一周年礼物—这是我们的故事

你知道吗,程序员表达爱意的方式,有时候真的很笨拙,但那份心意,却也真实得可爱,我和女朋友在一起快一年的时候,我突然想到一个点子:能不能用...

你知道吗,程序员表达爱意的方式,有时候真的很笨拙,但那份心意,却也真实得可爱,我和女朋友在一起快一年的时候,我突然想到一个点子:能不能用我吃饭的家伙——Go语言,把我们这一年里拍的零碎视频片段,自动拼接成一个完整的纪念视频?听起来挺酷的对吧,但真做起来,坑比想象中多得多。

为什么是Go语言,而不是Python或者别的

说实话,我一开始也考虑过用Python,毕竟Python处理视频的库多,网上教程也一堆,但后来我仔细想了想,还是决定用Go,原因很简单:

Go的性能真的不错,Go是编译型语言,没有解释器那层开销,处理大量文件IO和计算的时候,占用内存小,跑得又快,我们这一年拍了大概700多个小视频,加起来有几十个G的文件要处理,用Python处理这种量级,内存容易爆,而且说实话,进度条跑起来都卡卡的,在Go里,用filepath.Walk遍历文件夹,再配合goroutine并发处理,速度那叫一个痛快。

部署太方便了,我不需要女朋友的电脑上装Python环境,也不需要安装一堆依赖包,Go编译出来就一个二进制文件,双击就能跑,对她这种电脑小白来说,这点特别重要——她只需要把文件夹路径告诉我,其他什么都不用管,反正程序会自己找到所有视频文件。

还有一点,Go的标准库虽然不直接处理视频,但配合ffmpeg这种外部工具,简直是无缝衔接,Go负责调度和控制流程,ffmpeg负责干重活,各司其职,非常清晰。

第一步:先梳理清楚我们要做什么

写代码之前,我先把需求列了个清单,就跟写产品需求文档似的,还挺详细:

功能点 具体描述 优先级
文件扫描 读取指定目录下所有视频文件(支持.mp4, .mov, .avi等格式) 必须
元数据读取 提取每个视频的拍摄时间、时长、分辨率 必须
按时间排序 把视频按拍摄时间先后排列,不能乱了顺序 必须
片段截取 每个视频截取最精彩的部分(默认取前5秒) 建议
分辨率统一 不同手机拍的视频分辨率不一样,得统一缩放到1080p 建议
拼接合成 把几百个小片段无缝拼接成一个长视频 必须
背景音乐 选一首对我们有意义的歌作为背景音乐 可选
字幕叠加 在视频里打上“第XX天”的字幕,记录时间流逝 想得美

你看,我一开始想得可美了,字幕、音乐、转场效果,什么都要,结果真正写起来,光是把前两档做完就够呛了。

核心代码:用Go调度ffmpeg

这里放一段我写的核心代码,其实就是用exec.Command去调用外部的ffmpeg工具:

package main
import (
    "fmt"
    "log"
    "os"
    "os/exec"
    "path/filepath"
    "sort"
    "strconv"
    "strings"
    "sync"
    "time"
)
// 视频文件信息
type VideoFile struct {
    Path     string
    ModTime  time.Time
    Duration float64
}
// 扫描目录,找出所有视频文件
func scanVideos(dir string) ([]VideoFile, error) {
    var videos []VideoFile
    err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
        if err != nil {
            return err
        }
        // 只看扩展名,简单粗暴
        ext := strings.ToLower(filepath.Ext(path))
        switch ext {
        case ".mp4", ".mov", ".avi", ".mkv", ".m4v":
            videos = append(videos, VideoFile{
                Path:    path,
                ModTime: info.ModTime(),
            })
        }
        return nil
    })
    if err != nil {
        return nil, err
    }
    // 按拍摄时间排序
    sort.Slice(videos, func(i, j int) bool {
        return videos[i].ModTime.Before(videos[j].ModTime)
    })
    return videos, nil
}
// 获取视频时长,用ffprobe
func getDuration(path string) (float64, error) {
    cmd := exec.Command("ffprobe", "-v", "error",
        "-show_entries", "format=duration",
        "-of", "default=noprint_wrappers=1:nokey=1", path)
    output, err := cmd.Output()
    if err != nil {
        return 0, err
    }
    duration, err := strconv.ParseFloat(strings.TrimSpace(string(output)), 64)
    if err != nil {
        return 0, err
    }
    return duration, nil
}
// 截取视频片段,统一转为1080p,并生成中间文件
func extractClip(video VideoFile, clipIndex int, workDir string) (string, error) {
    // 每个片段取前5秒
    outputPath := filepath.Join(workDir, fmt.Sprintf("clip_%04d.mp4", clipIndex))
    // 缩放 + 截取 + 编码
    cmd := exec.Command("ffmpeg", "-y",
        "-i", video.Path,
        "-t", "5", // 时长5秒
        "-vf", "scale=1920:1080:force_original_aspect_ratio=decrease,pad=1920:1080:(ow-iw)/2:(oh-ih)/2",
        "-c:v", "libx264", "-preset", "fast", "-crf", "23",
        "-c:a", "aac", "-b:a", "128k",
        "-movflags", "+faststart",
        outputPath)
    output, err := cmd.CombinedOutput()
    if err != nil {
        log.Printf("处理文件 %s 失败: %v", video.Path, err)
        log.Printf("ffmpeg输出: %s", string(output))
        return "", err
    }
    return outputPath, nil
}
// 并发处理所有视频
func extractAllClips(videos []VideoFile, workDir string) ([]string, error) {
    var wg sync.WaitGroup
    clipPaths := make([]string, len(videos))
    // 用channel控制并发数,避免一次开太多进程导致卡死
    sem := make(chan struct{}, 4) // 最多同时4个ffmpeg进程
    for i, video := range videos {
        wg.Add(1)
        go func(idx int, v VideoFile) {
            defer wg.Done()
            sem <- struct{}{} // 获取信号量
            defer func() { <-sem }() // 释放信号量
            clipPath, err := extractClip(v, idx, workDir)
            if err != nil {
                log.Printf("视频处理出错: %v", err)
                // 失败就跳过这个片段,不中断整个流程
                return
            }
            clipPaths[idx] = clipPath
        }(i, video)
    }
    wg.Wait()
    // 过滤掉空路径
    var validClips []string
    for _, p := range clipPaths {
        if p != "" {
            validClips = append(validClips, p)
        }
    }
    return validClips, nil
}
func main() {
    // 我这里写死了路径,实际你可以让用户输入
    sourceDir := "/Users/me/Videos/our_year"
    workDir := "/tmp/anniversary"
    // 创建临时工作目录
    os.MkdirAll(workDir, 0755)
    // 1. 扫描视频
    fmt.Println("正在扫描视频文件...")
    videos, err := scanVideos(sourceDir)
    if err != nil {
        log.Fatalf("扫描目录失败: %v", err)
    }
    fmt.Printf("找到 %d 个视频文件\n", len(videos))
    // 2. 提取片段
    fmt.Println("正在提取视频片段,并发处理中...")
    clips, err := extractAllClips(videos, workDir)
    if err != nil {
        log.Fatalf("提取片段失败: %v", err)
    }
    fmt.Printf("成功处理 %d 个片段\n", len(clips))
    // 3. 生成文件列表
    listFile := filepath.Join(workDir, "list.txt")
    listContent := ""
    for _, clip := range clips {
        listContent += "file '" + clip + "'\n"
    }
    os.WriteFile(listFile, []byte(listContent), 0644)
    // 4. 合并所有片段
    outputFile := "/Users/me/Videos/anniversary_365.mp4"
    cmd := exec.Command("ffmpeg", "-y",
        "-f", "concat", "-safe", "0",
        "-i", listFile,
        "-c", "copy",
        outputFile)
    if output, err := cmd.CombinedOutput(); err != nil {
        log.Fatalf("合并视频失败: %v", err)
        log.Printf("ffmpeg输出: %s", string(output))
    }
    fmt.Printf("视频已生成!保存位置: %s\n", outputFile)
}

你看,核心逻辑其实并不复杂,就是扫描文件提取片段拼接这么三步,但真正折磨人的,是各种边界情况。

遇到的坑,一个一个填

我遇到的第一大坑就是视频方向问题,女朋友用手机竖着拍的视频,在手机上看好好的,但用ffmpeg转出来之后,画面是横着的,头还得歪着看,后来我发现需要用ffprobe去读视频的旋转信息(rotate元数据),然后在ffmpeg-vf参数里加上transpose=1之类的旋转指令,折腾了好半天才搞定,差点就准备放弃旋转处理,让女朋友歪着头看了。

还有一个坑是音频采样率不统一,有些视频是48kHz的音频,有些是44.1kHz的,拼接的时候会有一段音画不同步,后来我在extractClip里强制指定了音频参数-ar 44100,问题就解决了。

中间文件占用空间超大,我们70多个G的原始视频,提取出来的片段加起来也差不多有40多个G,如果你电脑硬盘不够大,建议处理完一批删一批,或者直接在内存里做管道(但我没搞那么高级)。

为什么建议用并发?因为真的很慢

单线程处理700个视频,每个要花3~5秒,算下来得一个多小时,但Go的goroutine+信号量并发控制,4个ffmpeg同时跑,速度能提上来好几倍,差不多20多分钟就搞定了,而且Go的调度器很聪明,不会让CPU空转,也不会让内存爆掉。

不过得提醒一句,并发数别开太高,我一开始贪快,设了个10并发,结果4核的Mac直接卡成PPT,后来调到4,就舒服多了,你要是用Windows机器,建议2就差不多了。

这不是一篇技术教程,这是我们的纪念日

说实话,写到最后这个程序能不能跑起来已经不重要了,重要的是这个过程——我把我们这一年拍的所有视频都翻了一遍,看到了很多我早就忘记的瞬间:第一次一起做饭时手忙脚乱的样子,在楼道里偶遇时她惊喜的表情,深夜视频电话里的笑脸,还有我们养的那只傻猫追着激光笔跑的蠢样

技术本身没有温度,但代码里我写的那些注释,每条都是我想对她说的话,比如在处理视频截取的函数旁边,我写了一行:

// 这里取前5秒,希望在最短的时间里,留住最开心的那个瞬间

她看到之后,眼睛红了,这就够了。

关于Go语言,再多说一句

如果你也想做类似的事,Go真的特别合适,别被编译型语言那几个命令吓跑,它的语法比C简单太多了,而且标准库里的os/execfilepathsync拿来就能用,非常顺手,官方的文档也很友好,去装了Go环境,go run main.go就跑起来了。

我的程序里其实还留了个彩蛋,在合并完所有片段之后,我特意用ffmpeg给视频末尾加了一段黑场,上面盖了个字幕:

用Go语言写了个程序,把365天的视频拼成了一周年礼物—这是我们的故事

*感谢你在2024年4月1日选中我,一个会写代码但不太会说情话的男孩。*

虽然那个字幕的字体渲染有点瑕疵,位置偏了一点点,但……这不就是我们这种理工男的风格吗,有点笨拙,但很认真。

本文来自作者[kyadmin]投稿,不代表be365立场,如若转载,请注明出处:http://hljbesthome.com/lvyou/2457.html

(3)

文章推荐

发表回复

本站作者才能评论

评论列表(4条)

  • kyadmin
    kyadmin 2026-08-23

    我是be365的签约作者“kyadmin”!

  • kyadmin
    kyadmin 2026-08-23

    希望本篇文章《用Go语言写了个程序,把365天的视频拼成了一周年礼物—这是我们的故事》能对你有所帮助!

  • kyadmin
    kyadmin 2026-08-23

    本站[be365]内容主要涵盖:be365,be365网站,be365网址,ball365体育,Be365排名

  • kyadmin
    kyadmin 2026-08-23

    本文概览:你知道吗,程序员表达爱意的方式,有时候真的很笨拙,但那份心意,却也真实得可爱,我和女朋友在一起快一年的时候,我突然想到一个点子:能不能用...

    联系我们

    工作时间:周一至周五,9:30-18:30,节假日休息

    关注我们