211 lines
5.2 KiB
Go
211 lines
5.2 KiB
Go
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 10
|
|
case totalSeconds <= 300:
|
|
return 12
|
|
case totalSeconds <= 1000:
|
|
return 15
|
|
case totalSeconds <= 1200:
|
|
return 40
|
|
default:
|
|
return 60
|
|
}
|
|
}
|