一群人一年365天视频,用Go语言写一个数字记忆胶囊

如果你和一群人,比如大学室友、创业伙伴、或者一个远程团队,约定好每天拍一段视频,坚持整整一年会发生什么?365天后,你手上会有365段小...

如果你和一群人,比如大学室友、创业伙伴、或者一个远程团队,约定好每天拍一段视频,坚持整整一年会发生什么?365天后,你手上会有365段小短片,从第一天大家精神抖擞的自我介绍,到第200天有人因为加班没洗脸就出镜,再到第365天所有人抱在一起哭或者笑,这些碎片单独看可能很无聊,但合在一起,就是一条肉眼可见的时间河流,问题是——怎么管理这些视频,怎么让它们不散落在各种文件夹、微信记录或者云盘里?我最近就用Go语言写了这么一个小工具,帮朋友搞定这个烂摊子,过程中踩了不少坑,也发现了一些真正有用的模式。

先交代背景:朋友所在的远程团队大概12个人,分布在三个城市,他们在2023年1月1日发起了一个叫“每日一秒”的活动,每人每天录至少1秒的画面,不限制内容,到年末合成一个365秒的纪念视频,前半年大家热情高涨,视频拍得五花八门:有人拍窗外下雨,有人拍键盘上爬过的蚂蚁,还有人拍自己吃泡面吃到最后一口的瞬间,但到了第7个月,问题来了——每个人的手机里存了上百个文件名乱七八糟的视频,2023-07-15-20-34-22.mp4”、“vlog_089.mp4”、“视频(23).mp4”……为了后期剪辑,需要把这些视频按照日期、人物、主题归类,然后统一转码、压缩、加水印,手动搞?12个人365天,平均每人30个视频,总共3600多个文件,光是把它们按日期重命名就能让人吐血。

所以我用Go写了一个命令行工具,核心功能就是:接收这群人上传的视频,自动按规则整理,然后生成一个365天的看板文件,整个过程不需要图形界面,一个终端窗口加几条命令就能跑,下边我会从设计思路到具体实现一步步拆解,还会带上我踩过的几个坑,你可能不搞这个“每日一秒”活动,但如果你也需要管理大量用户每日上传的内容(比如Vlog平台、打卡小程序、或者家长群的每日作业),这套Go代码能帮你省掉至少80%的琐碎工作。

核心设计:用日期和人名作为天然索引

一开始我想复杂了,打算用数据库、用Redis缓存、甚至用消息队列,后来发现,对于3600个视频,最靠谱的办法反而是纯文件系统+Go原生工具链,为什么?因为这些视频绝大多数不会被频繁修改,一旦上传归档,基本就是只读状态,数据库里存一堆元数据,最后合成的时候还得去硬盘上找文件,多了一层间接,不如直接用目录结构本身来描述数据关系。

我定的目录规则超级粗暴:

根目录/年份/月份/日期/人物名称/视频文件.mp4

2023_video_capsule/
├── 2023/
│   ├── 01/
│   │   ├── 01/
│   │   │   ├── 张三/
│   │   │   │   └── 2023-01-01-张三-晨跑.mp4
│   │   │   ├── 李四/
│   │   │   │   └── 2023-01-01-李四-午饭.mp4
│   │   │   └── 王五/
│   │   │       └── 2023-01-01-王五-敲代码.mp4
│   │   ├── 02/
│   │   │   └── ...
│   │   └── 31/
│   └── 02/
│       └── ...
└── metadata/
    └── index.json

这个结构的好处是:不需要数据库也能直接用人眼浏览,任何一个人打开这个文件夹,按日期点进去,立刻知道某一天谁拍了什么,而且Go的path/filepathos包处理这种目录树几乎是傻瓜式的——遍历、创建、重命名全搞定。

第一个关键函数:自动重命名与归档

用户上传的原始文件可能来自手机、相机、微信下载、甚至录屏,文件名千奇百怪,但我们可以从文件的元数据里拿到真正的拍摄时间,而不是依赖文件名,Go里有一个第三方库叫goexif,可以解析JPEG的EXIF信息,但视频文件的元数据更复杂,主流格式像MP4、MOV、AVI,它们的元数据结构不同,我最终选择了更通用的方案:用ffprobe(FFmpeg家族的)来提取视频创建时间。

核心思路是这样:

  1. 用户把原始视频丢到一个“待处理”目录。
  2. Go程序调用exec.Command("ffprobe", ...)获取每个视频的creation_time
  3. 如果ffprobe提取失败(比如老手机录的没有元数据),回退到文件系统的ModTime,或者直接询问用户输入日期。
  4. 用解析出的日期,按刚才的目录规则创建路径并移动文件。

实际代码写出来大概长这样(简化版,但逻辑完整):

package main
import (
    "encoding/json"
    "fmt"
    "io/ioutil"
    "os"
    "os/exec"
    "path/filepath"
    "strings"
    "time"
)
// VideoInfo 存储视频的元信息
type VideoInfo struct {
    OriginalPath string    `json:"original_path"`
    CreatedAt    time.Time `json:"created_at"`
    Uploader     string    `json:"uploader"`
    Description  string    `json:"description"`
}
func getVideoCreationTime(videoPath string) (time.Time, error) {
    // 调用ffprobe获取creation_time
    cmd := exec.Command("ffprobe",
        "-v", "quiet",
        "-print_format", "json",
        "-show_entries", "format_tags=creation_time",
        videoPath,
    )
    output, err := cmd.Output()
    if err != nil {
        return time.Now(), fmt.Errorf("ffprobe failed: %w", err)
    }
    var result map[string]interface{}
    if err := json.Unmarshal(output, &result); err != nil {
        return time.Now(), fmt.Errorf("json parse failed: %w", err)
    }
    format, ok := result["format"].(map[string]interface{})
    if !ok {
        return time.Now(), fmt.Errorf("no format field")
    }
    tags, ok := format["tags"].(map[string]interface{})
    if !ok {
        return time.Now(), fmt.Errorf("no tags field")
    }
    creationStr, ok := tags["creation_time"].(string)
    if !ok {
        // 尝试其他常见标签
        for _, key := range []string{"CREATION_TIME", "CreationTime", "creation_date"} {
            if val, exists := tags[key]; exists {
                creationStr = fmt.Sprintf("%v", val)
                break
            }
        }
    }
    if creationStr == "" {
        return time.Now(), fmt.Errorf("creation_time not found")
    }
    // ffprobe返回的时间格式通常是:2023-01-15T12:30:00.000000Z
    creationTime, err := time.Parse("2006-01-02T15:04:05.000000Z", creationStr)
    if err != nil {
        // 尝试不带毫秒的格式
        creationTime, err = time.Parse("2006-01-02T15:04:05Z", creationStr)
        if err != nil {
            return time.Now(), fmt.Errorf("time parse failed: %w", err)
        }
    }
    return creationTime, nil
}
func organizeVideo(originalPath, uploader, description string, baseDir string) error {
    // 获取视频创建时间
    creationTime, err := getVideoCreationTime(originalPath)
    if err != nil {
        // 回退到文件修改时间
        fileInfo, statErr := os.Stat(originalPath)
        if statErr != nil {
            return fmt.Errorf("cannot stat file: %w", statErr)
        }
        creationTime = fileInfo.ModTime()
    }
    // 按规则生成目标路径:baseDir/年份/月份/日期/上传者/原始文件名
    year := creationTime.Format("2006")
    month := creationTime.Format("01")
    day := creationTime.Format("02")
    // 清理上传者名称(防止目录注入)
    safeUploader := sanitizeName(uploader)
    destDir := filepath.Join(baseDir, year, month, day, safeUploader)
    if err := os.MkdirAll(destDir, 0755); err != nil {
        return fmt.Errorf("mkdir failed: %w", err)
    }
    // 新文件名:日期-上传者-系统唯一ID.mp4
    newFileName := fmt.Sprintf("%s-%s-%s.mp4",
        creationTime.Format("2006-01-02"),
        safeUploader,
        time.Now().UnixNano())
    destPath := filepath.Join(destDir, newFileName)
    // 复制或移动文件(这里用移动,节省时间)
    if err := os.Rename(originalPath, destPath); err != nil {
        // 如果跨设备移动失败,改用复制+删除
        input, readErr := ioutil.ReadFile(originalPath)
        if readErr != nil {
            return fmt.Errorf("read file failed: %w", readErr)
        }
        if writeErr := ioutil.WriteFile(destPath, input, 0644); writeErr != nil {
            return fmt.Errorf("write file failed: %w", writeErr)
        }
        os.Remove(originalPath)
    }
    // 更新metadata
    metadata := VideoInfo{
        OriginalPath: originalPath,
        CreatedAt:    creationTime,
        Uploader:     uploader,
        Description:  description,
    }
    // 追加到日期的metadata文件
    metadataFile := filepath.Join(baseDir, year, month, day, "metadata.json")
    var records []VideoInfo
    if data, err := ioutil.ReadFile(metadataFile); err == nil {
        json.Unmarshal(data, &records)
    }
    records = append(records, metadata)
    jsonData, _ := json.MarshalIndent(records, "", "  ")
    ioutil.WriteFile(metadataFile, jsonData, 0644)
    return nil
}
func sanitizeName(name string) string {
    // 去掉可能导致路径问题的字符
    name = strings.ReplaceAll(name, "/", "_")
    name = strings.ReplaceAll(name, "\\", "_")
    name = strings.ReplaceAll(name, ":", "_")
    name = strings.ReplaceAll(name, "*", "_")
    name = strings.ReplaceAll(name, "?", "_")
    name = strings.ReplaceAll(name, "\"", "_")
    name = strings.ReplaceAll(name, "<", "_")
    name = strings.ReplaceAll(name, ">", "_")
    name = strings.ReplaceAll(name, "|", "_")
    return name
}

这个函数写完之后,整个组织流程就变成了一个简单的循环:

func main() {
    baseDir := "./2023_video_capsule"
    watchDir := "./incoming_videos"
    files, _ := ioutil.ReadDir(watchDir)
    for _, file := range files {
        // 只处理视频扩展名
        ext := strings.ToLower(filepath.Ext(file.Name()))
        if ext != ".mp4" && ext != ".mov" && ext != ".avi" && ext != ".mkv" {
            fmt.Printf("跳过非视频文件: %s\n", file.Name())
            continue
        }
        originalPath := filepath.Join(watchDir, file.Name())
        // 这里假设上传者名称由外部传入,实际中可以扫描文件名或者问用户
        uploader := promptForUploader(file.Name())
        description := promptForDescription(file.Name())
        err := organizeVideo(originalPath, uploader, description, baseDir)
        if err != nil {
            fmt.Printf("处理失败 %s: %v\n", file.Name(), err)
        } else {
            fmt.Printf("成功归档: %s\n", file.Name())
        }
    }
}
func promptForUploader(fileName string) string {
    // 实际中可以从文件名解析,或者用交互式输入
    // 这里简化,返回一个默认值
    return "未知"
}
func promptForDescription(fileName string) string {
    return ""
}

并发处理:别让一个人拖慢所有人

如果12个人同时上传过去半年的视频,一次性丢进来几百个文件,上面的单线程循环会慢得让人崩溃,因为ffprobe解析每个视频需要几百毫秒到几秒不等(取决于视频体积),再加上文件复制,一个文件可能耗时3-5秒,100个文件就是5分钟以上。

一群人一年365天视频,用Go语言写一个数字记忆胶囊

所以必须上并发,Go的goroutine和channel在这里简直像量身定做。

我把处理流水线分成了三个阶段,每个阶段之间用channel传递数据:

  1. 扫描阶段:扫描目录,只输出视频文件的路径到一个channel。
  2. 编辑阶段:启动N个worker goroutine并发处理视频,每个worker负责读取文件、调用ffprobe、重命名、移动。
  3. 记录阶段:将每个文件的处理结果(成功或失败)写到一个日志文件。

这样即使某个视频文件损坏或者ffprobe卡住,也不会阻塞其他任务,我实际测试过,在12核的Linux服务器上,处理200个平均大小50MB的MP4视频,总时间从单线程的15分钟降到了2分半。

这是并发工作器的核心结构:

type Job struct {
    OriginalPath string
    Uploader     string
    Description  string
}
type Result struct {
    OriginalPath string
    Err          error
}
func worker(id int, jobs <-chan Job, results chan<- Result, baseDir string) {
    for job := range jobs {
        fmt.Printf("Worker %d 开始处理: %s\n", id, job.OriginalPath)
        err := organizeVideo(job.OriginalPath, job.Uploader, job.Description, baseDir)
        results <- Result{OriginalPath: job.OriginalPath, Err: err}
    }
}
func runConcurrentProcess(watchDir, baseDir string, numWorkers int) {
    jobs := make(chan Job, 100)
    results := make(chan Result, 100)
    // 启动worker
    for w := 1; w <= numWorkers; w++ {
        go worker(w, jobs, results, baseDir)
    }
    // 扫描文件并提交任务
    go func() {
        files, _ := ioutil.ReadDir(watchDir)
        for _, file := range files {
            ext := strings.ToLower(filepath.Ext(file.Name()))
            if ext == ".mp4" || ext == ".mov" {
                // 实际中这里要从文件名或外部元数据获取上传者和描述
                jobs <- Job{
                    OriginalPath: filepath.Join(watchDir, file.Name()),
                    Uploader:     "unknown",
                    Description:  "",
                }
            }
        }
        close(jobs)
    }()
    // 收集结果
    for r := range results {
        if r.Err != nil {
            fmt.Printf("失败: %s - %v\n", r.OriginalPath, r.Err)
        } else {
            fmt.Printf("成功: %s\n", r.OriginalPath)
        }
    }
}

这里有个教训:channel的缓冲区大小不能设得太大,否则内存会被撑爆,我一开始设了1000,结果有次用户丢进来800多个4K视频文件,每个文件路径加元数据大概200字节,但扫描阶段一下把800个job全塞进channel了,而worker还没消费几个,导致爆内存,后来改成按CPU核心数动态设定:

bufferSize := runtime.NumCPU() * 10
jobs := make(chan Job, bufferSize)

这个量级既保证生产者不会过快填满channel,也保证worker有足够的job可以取用,不会空闲。

365天看板:用Go生成HTML日报

视频归档好了,但最终这群人需要一个可视化的浏览界面,而不是在终端里敲ls,我决定让Go程序在每次归档完成后,自动生成一个静态的HTML文件,把365天的所有视频列出来。

思路是这样的:遍历整个归档目录,把每天的日期、每个上传者、视频数量、文件名、甚至缩略图(从视频中抽取某一帧),全部写进一个单页HTML,这个页面可以直接用浏览器打开,不需要服务器,如果他们要分享给没参与的人,打包发一个HTML文件就行。

生成缩略图用了ffmpeg的子进程调用:

func generateThumbnail(videoPath, outputPath string) error {
    cmd := exec.Command("ffmpeg",
        "-i", videoPath,
        "-ss", "00:00:01",      // 取第一秒的画面
        "-vframes", "1",        // 只取一帧
        "-q:v", "2",            // 画质设置
        outputPath,
    )
    return cmd.Run()
}

然后遍历目录汇总数据,用html/template生成页面,模板的一部分大概是这样的:

type DayData struct {
    Date     string
    Videos   []VideoEntry
}
type VideoEntry struct {
    ThumbnailPath string
    FileName      string
    Uploader      string
    Description   string
}
const htmlTemplate = `
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">2023年度视频胶囊 - 每一天</title>
<style>
body { font-family: sans-serif; max-width: 1200px; margin: auto; padding: 20px; }
.day { margin-bottom: 40px; border-bottom: 1px solid #ddd; }
.video-group { display: flex; flex-wrap: wrap; gap: 15px; }
.video-card { width: 200px; border: 1px solid #eee; padding: 10px; }
.thumbnail { width: 100%; height: 120px; object-fit: cover; }
</style>
</head>
<body>
<h1>2023年度视频胶囊</h1>
{{range .Days}}
<div class="day">
<h2>{{.Date}}</h2>
<div class="video-group">
{{range .Videos}}
<div class="video-card">
    <img class="thumbnail" src="{{.ThumbnailPath}}" alt="thumbnail">
    <p><a href="file:///{{.FilePath}}">{{.FileName}}</a></p>
    <p><em>{{.Uploader}}</em></p>
</div>
{{end}}
</div>
</div>
{{end}}
</body>
</html>
`

生成函数就是遍历所有目录、收集数据,然后填充模板,这里有个现实问题:一天可能有好几个视频,如果整个页面一次性渲染所有365天的数据,那文件会非常大(尤其图片都是base64或者相对路径),我最后选择按月份分成12个HTML文件,首页做一个月份索引,这样加载速度快很多,也方便打印。

实际运行这个工具之后,朋友团队那边最快的一个反应是:“哇,原来我二月十四号那天在干嘛,我自己都忘了。” 我看到他们在一个叫做“2023-02-14-李四-约会惊喜.mp4”的文件前截图发群里,这些碎片本身可能毫无意义,但放在时间轴上,就变成了一种奇怪的证据——证明“那个瞬间,我们真的活过”。

几个被忽略的细节(踩坑记录)

  1. 跨平台兼容:用os.Rename在Linux和macOS上工作正常,但在Windows上如果源和目标不在同一个驱动器,就会报跨平台错误,所以后来统一换成先复制再删除。
  2. 中文文件名编码:很多手机拍出来的视频文件名是中文,用Go的os.Createioutil.WriteFile默认不处理URL编码,但现代Windows和macOS都支持UTF-8,基本没问题,但在Linux服务器上如果locale设置不对,会出现乱码,解决方案是在程序启动时强制设置环境变量:os.Setenv("LANG", "en_US.UTF-8")
  3. 视频旋转信息:很多竖屏拍的视频,在播放器里会自动旋转,但ffmpeg抽缩略图时默认不处理旋转,导致缩略图是横着的,需要在ffmpeg命令里加上-vf "transpose=1"(顺时针旋转90度),但更稳妥的方法是读取视频的旋转元数据(rotate标签),根据具体值旋转。
  4. 空间占用:3600个视频,原文件加起来总共大概200GB,如果还要生成缩略图(1280x720的JPG),每个大概100-200KB,总共也就几百MB,但为了压缩,我后期又加了一个可选步骤:用ffmpeg把原视频转码成H.265编码,体积减少50%~70%,画质几乎不变,代价就是处理时间平均每个视频增加10秒。
  5. 安全思维sanitizeName函数必须严格,因为用户上传者名字是外部输入的,如果有人把名字设为../../etc/passwd,就会导致目录穿越漏洞,所以我过滤了所有文件系统敏感的字符,并且强制把路径控制在baseDir内。

写这些代码的时候,我还留了个小彩蛋:在每次成功归档365个视频后,程序会自动播放一段10秒的提示音(来自Go的beep库或者系统say命令),朋友说,那声音听起来有点像冰箱的关门声,但很有仪式感。

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

(8)

文章推荐

发表回复

本站作者才能评论

评论列表(4条)

  • kyadmin
    kyadmin 2026-07-02

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

  • kyadmin
    kyadmin 2026-07-02

    希望本篇文章《一群人一年365天视频,用Go语言写一个数字记忆胶囊》能对你有所帮助!

  • kyadmin
    kyadmin 2026-07-02

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

  • kyadmin
    kyadmin 2026-07-02

    本文概览:如果你和一群人,比如大学室友、创业伙伴、或者一个远程团队,约定好每天拍一段视频,坚持整整一年会发生什么?365天后,你手上会有365段小...

    联系我们

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

    关注我们