你知道吗,程序员表达爱意的方式,有时候真的很笨拙,但那份心意,却也真实得可爱,我和女朋友在一起快一年的时候,我突然想到一个点子:能不能用我吃饭的家伙——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/exec、filepath、sync拿来就能用,非常顺手,官方的文档也很友好,去装了Go环境,go run main.go就跑起来了。
我的程序里其实还留了个彩蛋,在合并完所有片段之后,我特意用ffmpeg给视频末尾加了一段黑场,上面盖了个字幕:

*感谢你在2024年4月1日选中我,一个会写代码但不太会说情话的男孩。*
虽然那个字幕的字体渲染有点瑕疵,位置偏了一点点,但……这不就是我们这种理工男的风格吗,有点笨拙,但很认真。
本文来自作者[kyadmin]投稿,不代表be365立场,如若转载,请注明出处:http://hljbesthome.com/lvyou/2457.html
评论列表(4条)
我是be365的签约作者“kyadmin”!
希望本篇文章《用Go语言写了个程序,把365天的视频拼成了一周年礼物—这是我们的故事》能对你有所帮助!
本站[be365]内容主要涵盖:be365,be365网站,be365网址,ball365体育,Be365排名
本文概览:你知道吗,程序员表达爱意的方式,有时候真的很笨拙,但那份心意,却也真实得可爱,我和女朋友在一起快一年的时候,我突然想到一个点子:能不能用...