package main import ( "encoding/json" "fmt" "log/slog" "os/exec" "strconv" "strings" ) // FFprobe result structures type ProbeResult struct { Streams []ProbeStream `json:"streams"` Format ProbeFormat `json:"format"` } type ProbeStream struct { RFrameRate string `json:"r_frame_rate"` NbFrames string `json:"nb_frames,omitempty"` NbReadFrames string `json:"nb_read_frames,omitempty"` } type ProbeFormat struct { Duration string `json:"duration,omitempty"` } // ffprobe runs ffprobe and returns structured metadata func ffprobe(path string) (*ProbeResult, error) { cmd := exec.Command("ffprobe", "-v", "error", "-print_format", "json", "-show_format", "-show_streams", path, ) output, err := cmd.Output() if err != nil { return nil, fmt.Errorf("ffprobe failed: %w", err) } var result ProbeResult if err := json.Unmarshal(output, &result); err != nil { return nil, fmt.Errorf("parse ffprobe output: %w", err) } return &result, nil } // ffprobeFrameRate gets frame rate via direct ffprobe call (fallback) func ffprobeFrameRate(path string) (string, error) { cmd := exec.Command("ffprobe", "-v", "error", "-select_streams", "v", "-of", "default=noprint_wrappers=1:nokey=1", "-show_entries", "stream=r_frame_rate", path, ) output, err := cmd.Output() if err != nil { return "", fmt.Errorf("ffprobe frame rate failed: %w", err) } return strings.TrimSpace(string(output)), nil } // ffprobeFrameCountFullDecode counts frames via full decode (slow fallback) func ffprobeFrameCountFullDecode(path string) (uint64, error) { cmd := exec.Command("ffprobe", "-v", "error", "-count_frames", "-select_streams", "v:0", "-show_entries", "stream=nb_read_frames", "-of", "default=noprint_wrappers=1:nokey=1", path, ) output, err := cmd.Output() if err != nil { return 0, fmt.Errorf("ffprobe count frames failed: %w", err) } count, err := strconv.ParseUint(strings.TrimSpace(string(output)), 10, 64) if err != nil { return 0, fmt.Errorf("parse frame count: %w", err) } return count, nil } // parseFrameRate parses "num/denom" format frame rate func parseFrameRate(s string) (float64, error) { parts := strings.Split(s, "/") if len(parts) != 2 { return 0, fmt.Errorf("invalid frame rate format: %s", s) } num, err := strconv.ParseFloat(parts[0], 64) if err != nil { return 0, fmt.Errorf("parse numerator: %w", err) } denom, err := strconv.ParseFloat(parts[1], 64) if err != nil { return 0, fmt.Errorf("parse denominator: %w", err) } if num == 0 && denom == 0 { return 0, fmt.Errorf("zero frame rate") } return num / denom, nil } // calculateFrameRate gets frame rate with ffprobe fallback func calculateFrameRate(path string, frameRateStr string, firstRun bool) (float64, error) { frameRate, err := parseFrameRate(frameRateStr) if err != nil || frameRate == 0 { slog.Warn("got incorrect frame rate, calling ffprobe again...") if !firstRun { return 0, fmt.Errorf("couldn't get frame rate") } // Call ffprobe directly newFrameRateStr, err := ffprobeFrameRate(path) if err != nil { return 0, err } slog.Debug("raw ffprobe gave", "frameRate", newFrameRateStr) return calculateFrameRate(path, newFrameRateStr, false) } return frameRate, nil } // calculateFrameCount gets total frame count with fallbacks func calculateFrameCount(path string, stream *ProbeStream, format *ProbeFormat, frameRate float64) (uint64, error) { // Try stream metadata first if stream.NbFrames != "" { count, err := strconv.ParseUint(stream.NbFrames, 10, 64) if err == nil { return count, nil } } // Try format duration if format.Duration != "" { duration, err := strconv.ParseFloat(format.Duration, 64) if err == nil { slog.Warn("fetching duration from format metadata...") return uint64(duration * frameRate), nil } } // Fallback to full decode slog.Warn("file didn't provide frame metadata, calculating it ourselves...") return ffprobeFrameCountFullDecode(path) } // extractFrame extracts a single frame at the given index using ffmpeg func extractFrame(inputPath, outputPath string, frameIndex int, frameRate float64) error { // Calculate timeline position timelineIndex := float64(frameIndex) / frameRate timelineStr := fmt.Sprintf("%.5f", timelineIndex) slog.Debug("construct command", "timelineIndex", timelineIndex, "timelineStr", timelineStr) slog.Info("ffmpeg command", "args", fmt.Sprintf("-nostdin -y -ss %s -i %s -vframes 1 %s", timelineStr, inputPath, outputPath)) // Use fast seeking with -ss before -i cmd := exec.Command("ffmpeg", "-nostdin", "-y", "-ss", timelineStr, "-i", inputPath, "-vframes", "1", outputPath, ) output, err := cmd.CombinedOutput() if err != nil { slog.Error("ffmpeg failed", "output", string(output), "error", err) return fmt.Errorf("ffmpeg failed: %w", err) } slog.Debug("ffmpeg output", "output", string(output)) return nil } // getFrameSkipSeconds returns the frame sampling interval based on video duration func getFrameSkipSeconds(totalSeconds int) int { switch { case totalSeconds <= 10: return 1 case totalSeconds <= 60: return 5 case totalSeconds <= 120: return 7 case totalSeconds <= 300: return 10 case totalSeconds <= 1000: return 15 case totalSeconds <= 1200: return 20 default: return 26 } }