如果你和一群人,比如大学室友、创业伙伴、或者一个远程团队,约定好每天拍一段视频,坚持整整一年会发生什么?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/filepath和os包处理这种目录树几乎是傻瓜式的——遍历、创建、重命名全搞定。
第一个关键函数:自动重命名与归档
用户上传的原始文件可能来自手机、相机、微信下载、甚至录屏,文件名千奇百怪,但我们可以从文件的元数据里拿到真正的拍摄时间,而不是依赖文件名,Go里有一个第三方库叫goexif,可以解析JPEG的EXIF信息,但视频文件的元数据更复杂,主流格式像MP4、MOV、AVI,它们的元数据结构不同,我最终选择了更通用的方案:用ffprobe(FFmpeg家族的)来提取视频创建时间。
核心思路是这样:
- 用户把原始视频丢到一个“待处理”目录。
- Go程序调用
exec.Command("ffprobe", ...)获取每个视频的creation_time- 如果
ffprobe提取失败(比如老手机录的没有元数据),回退到文件系统的ModTime,或者直接询问用户输入日期。- 用解析出的日期,按刚才的目录规则创建路径并移动文件。
- 如果
实际代码写出来大概长这样(简化版,但逻辑完整):
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分钟以上。

所以必须上并发,Go的goroutine和channel在这里简直像量身定做。
我把处理流水线分成了三个阶段,每个阶段之间用channel传递数据:
- 扫描阶段:扫描目录,只输出视频文件的路径到一个channel。
- 编辑阶段:启动N个worker goroutine并发处理视频,每个worker负责读取文件、调用ffprobe、重命名、移动。
- 记录阶段:将每个文件的处理结果(成功或失败)写到一个日志文件。
这样即使某个视频文件损坏或者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”的文件前截图发群里,这些碎片本身可能毫无意义,但放在时间轴上,就变成了一种奇怪的证据——证明“那个瞬间,我们真的活过”。
几个被忽略的细节(踩坑记录)
- 跨平台兼容:用
os.Rename在Linux和macOS上工作正常,但在Windows上如果源和目标不在同一个驱动器,就会报跨平台错误,所以后来统一换成先复制再删除。 - 中文文件名编码:很多手机拍出来的视频文件名是中文,用Go的
os.Create和ioutil.WriteFile默认不处理URL编码,但现代Windows和macOS都支持UTF-8,基本没问题,但在Linux服务器上如果locale设置不对,会出现乱码,解决方案是在程序启动时强制设置环境变量:os.Setenv("LANG", "en_US.UTF-8")。 - 视频旋转信息:很多竖屏拍的视频,在播放器里会自动旋转,但
ffmpeg抽缩略图时默认不处理旋转,导致缩略图是横着的,需要在ffmpeg命令里加上-vf "transpose=1"(顺时针旋转90度),但更稳妥的方法是读取视频的旋转元数据(rotate标签),根据具体值旋转。 - 空间占用:3600个视频,原文件加起来总共大概200GB,如果还要生成缩略图(1280x720的JPG),每个大概100-200KB,总共也就几百MB,但为了压缩,我后期又加了一个可选步骤:用
ffmpeg把原视频转码成H.265编码,体积减少50%~70%,画质几乎不变,代价就是处理时间平均每个视频增加10秒。 - 安全思维:
sanitizeName函数必须严格,因为用户上传者名字是外部输入的,如果有人把名字设为../../etc/passwd,就会导致目录穿越漏洞,所以我过滤了所有文件系统敏感的字符,并且强制把路径控制在baseDir内。
写这些代码的时候,我还留了个小彩蛋:在每次成功归档365个视频后,程序会自动播放一段10秒的提示音(来自Go的beep库或者系统say命令),朋友说,那声音听起来有点像冰箱的关门声,但很有仪式感。
本文来自作者[kyadmin]投稿,不代表be365立场,如若转载,请注明出处:http://www.hljbesthome.com/jiankang/1074.html
评论列表(4条)
我是be365的签约作者“kyadmin”!
希望本篇文章《一群人一年365天视频,用Go语言写一个数字记忆胶囊》能对你有所帮助!
本站[be365]内容主要涵盖:be365,be365网站,be365网址,ball365体育,Be365排名
本文概览:如果你和一群人,比如大学室友、创业伙伴、或者一个远程团队,约定好每天拍一段视频,坚持整整一年会发生什么?365天后,你手上会有365段小...