glimbus/video.go
2026-01-11 19:42:16 -03:00

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 7
case totalSeconds <= 300:
return 10
case totalSeconds <= 1000:
return 15
case totalSeconds <= 1200:
return 20
default:
return 26
}
}