package main import ( "bytes" "fmt" "io/fs" "log" "os" "os/exec" "path/filepath" "regexp" "sort" "strconv" "strings" "sync" "time" ) // Movie 电影信息结构 type Movie struct { FileName string `json:"filename"` // 原始文件名(如 "video1.mp4") Image string `json:"image"` // 对应的缩略图文件名(如 "video1.png") Duration int `json:"duration"` // 视频时长(分钟) TimeCategory string `json:"time_category"` // 时长分类(如 "15min", "30min") VideoPath string `json:"-"` // 视频文件完整路径(用于ffmpeg) CreatedTime int64 `json:"created_time"` // 文件创建时间戳 mu sync.Mutex // 保护视频文件的并发访问 } func (m *Movie) RenameVideo(newName string) error { m.mu.Lock() defer m.mu.Unlock() if newName == m.FileName { return nil // 如果新名称与当前名称相同,则不需要重命名 } // 获取文件扩展名 videoExt := filepath.Ext(m.FileName) imageExt := filepath.Ext(m.Image) // 获取当前文件的目录 dir := filepath.Dir(m.VideoPath) // 构建新的文件名(不带扩展名) newBaseName := strings.TrimSuffix(newName, videoExt) // 重命名视频文件 oldVideoPath := m.VideoPath newVideoPath := filepath.Join(dir, newBaseName+videoExt) if err := os.Rename(oldVideoPath, newVideoPath); err != nil { return fmt.Errorf("重命名视频文件失败: %w", err) } // 重命名缩略图文件 oldImagePath := filepath.Join(dir, m.Image) newImagePath := filepath.Join(dir, newBaseName+imageExt) if _, err := os.Stat(oldImagePath); err == nil { if err := os.Rename(oldImagePath, newImagePath); err != nil { // 如果缩略图重命名失败,尝试回滚视频文件重命名 _ = os.Rename(newVideoPath, oldVideoPath) return fmt.Errorf("重命名缩略图失败: %w", err) } m.Image = newBaseName + imageExt } // 更新结构体信息 m.VideoPath = newVideoPath m.FileName = newBaseName + videoExt return nil } var Movies []*Movie // 存储所有电影信息的全局切片 var MovieDict = make(map[string]*Movie) // 存储需要处理缩略图的视频字典 var MovieDictLock sync.Mutex // 保护MovieDict的并发访问 var IsRemakePNG = true // 是否重新生成所有PNG缩略图 var Categories = []string{ // 分类 "15min", "30min", "60min", "大于60min", "最新添加"} // getVideoDuration 获取视频时长(秒) func getVideoDuration(videoPath string) (float64, error) { cmd := exec.Command("ffmpeg", "-i", videoPath) var stderr bytes.Buffer cmd.Stderr = &stderr _ = cmd.Run() // 忽略错误,ffmpeg在没有输出文件时会返回错误 // 解析ffmpeg输出的时长信息 output := stderr.String() re := regexp.MustCompile(`Duration: (\d{2}):(\d{2}):(\d{2}\.\d{2})`) matches := re.FindStringSubmatch(output) // 尝试匹配不含毫秒的格式 if len(matches) < 4 { reAlt := regexp.MustCompile(`Duration: (\d{2}):(\d{2}):(\d{2})`) matches = reAlt.FindStringSubmatch(output) if len(matches) < 4 { return 0, fmt.Errorf("无法在ffmpeg输出中找到时长: %s. 输出: %s", filepath.Base(videoPath), output) } matches[3] += ".00" // 添加毫秒部分保持格式一致 } // 转换为秒数 hours, _ := strconv.ParseFloat(matches[1], 64) minutes, _ := strconv.ParseFloat(matches[2], 64) seconds, _ := strconv.ParseFloat(matches[3], 64) return hours*3600 + minutes*60 + seconds, nil } // getFileCreationTime 获取文件创建时间戳 func getFileCreationTime(path string) (int64, error) { fileInfo, err := os.Stat(path) if err != nil { return 0, err } return fileInfo.ModTime().Unix(), nil } // scanMovieFiles 扫描movie目录并返回文件列表 func scanMovieFiles() ([]string, error) { files, err := filepath.Glob("movie/*") if err != nil { return nil, fmt.Errorf("扫描movie目录失败: %w", err) } return files, nil } // createMovieDict 创建需要处理缩略图的视频字典 func createMovieDict(files []string) map[string]*Movie { movieDict := make(map[string]*Movie) for _, fullPath := range files { filename := filepath.Base(fullPath) ext := filepath.Ext(filename) baseName := strings.TrimSuffix(filename, ext) // 跳过目录和系统文件 if ext == ".db" { continue } // 处理PNG文件 if ext == ".png" { if _, exists := movieDict[baseName]; exists && !IsRemakePNG { delete(movieDict, baseName) } continue } // 处理视频文件 if ext != "" { if _, exists := movieDict[baseName]; !exists || IsRemakePNG { movieDict[baseName] = &Movie{VideoPath: fullPath} } } } return movieDict } // processVideoFile 处理单个视频文件 func processVideoFile(path string, movieDict map[string]*Movie) error { filename := filepath.Base(path) baseName := strings.TrimSuffix(filename, filepath.Ext(filename)) // 获取视频时长 durationSec, err := getVideoDuration(path) if err != nil { delete(movieDict, baseName) return fmt.Errorf("获取时长失败: %s: %w", filename, err) } // 获取文件创建时间 createdTime, err := getFileCreationTime(path) if err != nil { return fmt.Errorf("获取创建时间失败: %s: %w", filename, err) } // 转换为分钟并分类 durationMin := int(durationSec / 60) timeCat := getTimeCategory(durationMin) // 创建电影记录 movie := &Movie{ FileName: filename, Image: baseName + ".png", Duration: durationMin, TimeCategory: timeCat, VideoPath: path, CreatedTime: createdTime, } Movies = append(Movies, movie) MovieDict[movie.FileName] = movie // 添加到全局字典 // 更新缩略图字典 if entry, exists := movieDict[baseName]; exists { entry.FileName = movie.FileName entry.Duration = movie.Duration entry.VideoPath = movie.VideoPath entry.CreatedTime = movie.CreatedTime } return nil } // getTimeCategory 根据时长获取分类 func getTimeCategory(durationMin int) string { switch { case durationMin <= 15: return "15min" case durationMin <= 30: return "30min" case durationMin <= 60: return "60min" default: return "大于60min" } } // generateThumbnail 生成单个视频缩略图 func generateThumbnail(movie *Movie) { baseName := strings.TrimSuffix(movie.FileName, filepath.Ext(movie.FileName)) outputPath := filepath.Join("movie", baseName+".png") // --- Start of improved logic --- // Fixed skip for the beginning of the video, as per original code's ffmpeg args const skipStartSeconds = 10.0 // Number of frames we want in the tile (for a 3x3 grid) const numFramesInTile = 9.0 // Use float for calculations durationAfterSkip := float64(movie.Duration) - skipStartSeconds var intervalSeconds float64 if durationAfterSkip <= 0.1 { // Use a small threshold like 0.1s intervalSeconds = 0.05 // A very small interval to grab whatever is there. } else { if numFramesInTile > 1 { intervalSeconds = durationAfterSkip / (numFramesInTile - 1.0) } else { intervalSeconds = durationAfterSkip } if intervalSeconds <= 0 { intervalSeconds = 0.05 // Fallback to a tiny interval } } filter := fmt.Sprintf("select='isnan(prev_selected_t)+gte(t-prev_selected_t\\,%.3f)',scale=320:180,tile=3x3", intervalSeconds) log.Printf("Movie: %s, OriginalDuration: %ds, SkipStart: %.1fs, DurationForSampling: %.2fs, CalculatedInterval: %.3fs", movie.FileName, movie.Duration, skipStartSeconds, durationAfterSkip, intervalSeconds) // Execute ffmpeg command cmd := exec.Command("ffmpeg", "-ss", fmt.Sprintf("%.0f", skipStartSeconds), // Using the defined skipStartSeconds "-i", movie.VideoPath, "-vf", filter, "-frames:v", "1", // Crucial for outputting a single tiled image "-q:v", "3", // Quality for the frames before tiling/PNG compression "-y", // Overwrite output file if it exists outputPath, ) var stderr bytes.Buffer cmd.Stderr = &stderr startTime := time.Now() if err := cmd.Run(); err != nil { // Assuming movie.Duration is in seconds, adjusted log from (%d min) to (%d s) log.Printf("生成缩略图失败: %s (%d s): %v\n错误输出: %s", movie.VideoPath, movie.Duration, err, stderr.String()) } else { log.Printf("成功生成缩略图: %s (耗时: %v)", outputPath, time.Since(startTime)) } } // initMovie 初始化电影数据主函数 func initMovie() { // 扫描目录获取文件列表 files, err := scanMovieFiles() if err != nil { log.Fatal(err) } // 创建需要处理缩略图的视频字典 movieDict := createMovieDict(files) Movies = make([]*Movie, 0) // 重置全局电影列表 // 遍历处理每个视频文件 err = filepath.WalkDir("./movie", func(path string, d fs.DirEntry, err error) error { if err != nil || d.IsDir() || filepath.Ext(d.Name()) == ".png" || filepath.Ext(d.Name()) == ".db" { return err } return processVideoFile(path, movieDict) }) if err != nil { log.Fatalf("遍历movie目录失败: %v", err) } // 为最近添加的文件添加"最新添加"类别 markRecentMovies() // 排序电影列表 // sortMovies() // 生成缩略图 for _, movie := range movieDict { if movie.VideoPath == "" { continue } generateThumbnail(movie) } log.Printf("成功初始化电影数据,共找到 %d 个视频文件", len(Movies)) } // markRecentMovies 标记最近添加的电影 func markRecentMovies() { // 首先按创建时间排序 sort.Slice(Movies, func(i, j int) bool { return Movies[i].CreatedTime > Movies[j].CreatedTime }) // 标记前20个最新文件为"最新添加"类别 for i := 0; i < len(Movies) && i < 20; i++ { Movies[i].TimeCategory = "最新添加" } }