x-movie/server/movie.go
2025-06-02 03:19:36 +08:00

241 lines
9.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package main
import (
"bytes"
"fmt"
"io/fs"
"log"
"os/exec"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
)
// Movie 电影结构
type Movie struct {
Name string `json:"filename"` // Original filename of the movie, e.g., "video1.mp4"
Image string `json:"image"` // Corresponding image filename, e.g., "video1.png"
Duration int `json:"duration"` // Duration in minutes
TimeCategory string `json:"time_category"` // 分类名称, e.g., "15min", "30min"
VideoPath string `json:"-"` // Full path to the video file, used for ffmpeg
}
var movies []Movie // This will store all movies
var IsRemakePNG = false // Set to true to regenerate all PNGs
func getVideoDuration(videoPath string) (float64, error) {
// 使用ffmpeg获取视频信息
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 {
// Try to find duration even if format is slightly different (e.g. no milliseconds)
reAlt := regexp.MustCompile(`Duration: (\d{2}):(\d{2}):(\d{2})`)
matches = reAlt.FindStringSubmatch(output)
if len(matches) < 4 {
return 0, fmt.Errorf("duration not found in ffmpeg output for %s. Output: %s", filepath.Base(videoPath), output)
}
// Add .00 for consistency if seconds has no decimal part
matches[3] += ".00"
}
// 将时间转换为秒
hours, errH := strconv.ParseFloat(matches[1], 64)
minutes, errM := strconv.ParseFloat(matches[2], 64)
seconds, errS := strconv.ParseFloat(matches[3], 64)
if errH != nil || errM != nil || errS != nil {
return 0, fmt.Errorf("error parsing time components from ffmpeg output: %v, %v, %v", errH, errM, errS)
}
duration := hours*3600 + minutes*60 + seconds
return duration, nil
}
func initMovie() {
// movieDict stores movies for which thumbnails might need to be generated.
// Key: base filename without extension (e.g., "video1")
// Value: *Movie object (initially with Name as full path to video, Duration updated later)
var movieDict map[string]*Movie = make(map[string]*Movie)
// Glob for all files in "movie/" directory to check for existing PNGs
allFiles, err := filepath.Glob("movie/*")
if err != nil {
log.Printf("Error globbing movie directory: %v", err)
// Decide if you want to return or continue with an empty list
}
// This loop is to determine which videos need thumbnails.
// If a video `video.mp4` exists but `video.png` also exists,
// `video.mp4` is removed from `movieDict` unless `IsRemakePNG` is true.
for _, fullPath := range allFiles {
baseWithExt := filepath.Base(fullPath)
ext := filepath.Ext(baseWithExt)
baseName := strings.TrimSuffix(baseWithExt, ext) // Filename without extension
if ext == ".png" {
// If it's a PNG, check if its corresponding video is in the dict
if movieEntry, ok := movieDict[baseName]; ok {
if !IsRemakePNG {
// PNG exists and we are not remaking, so remove video from thumbnail generation list
log.Printf("Thumbnail %s already exists. Skipping generation for %s.", baseWithExt, movieEntry.VideoPath)
delete(movieDict, baseName)
}
}
} else if ext != "" && ext != ".db" { // Assume other non-empty extensions are videos
// If it's a video file (or any non-PNG, non-empty extension file)
if _, ok := movieDict[baseName]; !ok {
// If not already processed (e.g. by a .png file check) and not forced remake
movieDict[baseName] = &Movie{VideoPath: fullPath} // Store full path for ffmpeg
} else if IsRemakePNG {
// If it was already there (e.g. from a PNG that we decided to remake), ensure VideoPath is set
movieDict[baseName].VideoPath = fullPath
}
}
}
// Reset the global movies slice
movies = []Movie{}
// Walk through the movie directory to process video files
err = filepath.WalkDir("./movie", func(path string, d fs.DirEntry, err error) error {
if err != nil {
log.Printf("Error accessing path %q: %v\n", path, err)
return err
}
if d.IsDir() || filepath.Ext(d.Name()) == ".png" || filepath.Ext(d.Name()) == ".db" { // Skip directories, PNGs, and DB files
return nil
}
videoFileName := d.Name() // e.g., "video1.mp4"
baseNameWithoutExt := strings.TrimSuffix(videoFileName, filepath.Ext(videoFileName))
durationSec, err := getVideoDuration(path)
if err != nil {
// log.Printf("Error getting duration for %s: %v. Skipping this file.", videoFileName, err)
// Remove from movieDict if it was there, as we can't process it
delete(movieDict, baseNameWithoutExt)
return nil // Continue with next file
}
durationMin := int(durationSec / 60.0)
var timeCat string
if durationMin <= 15 {
timeCat = "15min"
} else if durationMin <= 30 {
timeCat = "30min"
} else if durationMin <= 60 {
timeCat = "60min"
} else {
timeCat = "大于60min"
}
movie := Movie{
Name: videoFileName,
Image: baseNameWithoutExt + ".png",
Duration: durationMin,
TimeCategory: timeCat,
VideoPath: path, // Store full path
}
movies = append(movies, movie)
// If this movie base name is in movieDict (meaning it needs/might need a thumbnail),
// update its duration.
if mEntry, ok := movieDict[baseNameWithoutExt]; ok {
mEntry.Duration = movie.Duration // Update duration for thumbnail filter logic
// Ensure VideoPath in movieDict is the one we just processed (it should be)
mEntry.VideoPath = path
mEntry.Name = movie.Name // Also update Name for logging in thumbnail part
}
return nil
})
if err != nil {
log.Printf("Error walking the path ./movie: %v\n", err)
}
// Sort the global movies list.
// First by custom time category order, then by duration within that category.
categoryOrderMap := map[string]int{
"15min": 0,
"30min": 1,
"60min": 2,
"大于60min": 3,
}
sort.Slice(movies, func(i, j int) bool {
catOrderI := categoryOrderMap[movies[i].TimeCategory]
catOrderJ := categoryOrderMap[movies[j].TimeCategory]
if catOrderI != catOrderJ {
return catOrderI < catOrderJ
}
return movies[i].Duration < movies[j].Duration
})
// Generate thumbnails for movies in movieDict
// These are the movies for which PNGs didn't exist or IsRemakePNG is true
for key, movieToProcess := range movieDict {
if movieToProcess.VideoPath == "" {
// log.Printf("Skipping thumbnail for %s, VideoPath is empty (likely only PNG found and no corresponding video processed).", key)
continue
}
// movieToProcess.Duration should have been updated by the WalkDir logic.
// If a video was in movieDict but not found/processed by WalkDir, its Duration might be 0.
if movieToProcess.Duration == 0 && movieToProcess.Name != "" { // Name check to avoid new entries
// Try to get duration again if it's missing, might happen if it was added to dict but WalkDir failed for it
// Or if the logic for updating movieDict was missed for some edge case.
// For simplicity, we assume duration is now correctly populated from WalkDir pass.
log.Printf("Warning: Duration for %s (path: %s) is 0, thumbnail filter might be suboptimal.", movieToProcess.Name, movieToProcess.VideoPath)
}
log.Printf("Generating thumbnail for: %s (Duration: %d min, Path: %s)", movieToProcess.Name, movieToProcess.Duration, movieToProcess.VideoPath)
var filter string
// Use movieToProcess.Duration for filter logic
if movieToProcess.Duration <= 2 {
filter = "select='isnan(prev_selected_t)+gte(t-prev_selected_t\\,5)',scale=320:180,tile=3x3"
} else if movieToProcess.Duration <= 5 {
filter = "select='isnan(prev_selected_t)+gte(t-prev_selected_t\\,10)',scale=320:180,tile=3x3"
} else if movieToProcess.Duration <= 30 {
filter = "select='isnan(prev_selected_t)+gte(t-prev_selected_t\\,20)',scale=320:180,tile=3x3"
} else if movieToProcess.Duration <= 60 {
filter = "select='isnan(prev_selected_t)+gte(t-prev_selected_t\\,35)',scale=320:180,tile=3x3"
} else { // duration > 60
filter = "select='isnan(prev_selected_t)+gte(t-prev_selected_t\\,60)',scale=320:180,tile=3x3"
}
outputImagePath := filepath.Join("movie", key+".png")
cmd := exec.Command("ffmpeg",
"-i", movieToProcess.VideoPath, // Use full path to video
"-vf", filter,
"-frames:v", "1",
"-q:v", "3", // Quality for JPEG/PNG, usually 2-5 is good for -qscale:v
"-y", // Overwrite output files without asking
outputImagePath,
)
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
log.Printf("Error generating thumbnail for %s (Duration: %d min): %v\nStderr: %s",
movieToProcess.VideoPath, movieToProcess.Duration, err, stderr.String())
// Consider whether to panic or just log
// panic(fmt.Errorf("could not generate frame %s %d. Error: %v. Stderr: %s", movieToProcess.VideoPath, movieToProcess.Duration, err, stderr.String()))
} else {
log.Printf("Successfully generated thumbnail: %s", outputImagePath)
}
}
}