VideoTools/internal/interlace/detector.go
Stu Leak cf9422ad6b Format cleanup and minor fixes
- Apply go formatting across internal packages
- Clean up imports and code style
2025-12-23 21:56:47 -05:00

232 lines
7.2 KiB
Go

package interlace
import (
"bufio"
"context"
"fmt"
"os/exec"
"regexp"
"strconv"
"strings"
)
// DetectionResult contains the results of interlacing analysis
type DetectionResult struct {
// Frame counts from idet filter
TFF int // Top Field First frames
BFF int // Bottom Field First frames
Progressive int // Progressive frames
Undetermined int // Undetermined frames
TotalFrames int // Total frames analyzed
// Calculated metrics
InterlacedPercent float64 // Percentage of interlaced frames
Status string // "Progressive", "Interlaced", "Mixed"
FieldOrder string // "TFF", "BFF", "Unknown"
Confidence string // "High", "Medium", "Low"
// Recommendations
Recommendation string // Human-readable recommendation
SuggestDeinterlace bool // Whether deinterlacing is recommended
SuggestedFilter string // "yadif", "bwdif", etc.
}
// Detector analyzes video for interlacing
type Detector struct {
FFmpegPath string
FFprobePath string
}
// NewDetector creates a new interlacing detector
func NewDetector(ffmpegPath, ffprobePath string) *Detector {
return &Detector{
FFmpegPath: ffmpegPath,
FFprobePath: ffprobePath,
}
}
// Analyze performs interlacing detection on a video file
// sampleFrames: number of frames to analyze (0 = analyze entire video)
func (d *Detector) Analyze(ctx context.Context, videoPath string, sampleFrames int) (*DetectionResult, error) {
// Build FFmpeg command with idet filter
args := []string{
"-i", videoPath,
"-filter:v", "idet",
"-frames:v", fmt.Sprintf("%d", sampleFrames),
"-an", // No audio
"-f", "null",
"-",
}
if sampleFrames == 0 {
// Remove frame limit to analyze entire video
args = []string{
"-i", videoPath,
"-filter:v", "idet",
"-an",
"-f", "null",
"-",
}
}
cmd := exec.CommandContext(ctx, d.FFmpegPath, args...)
// Capture stderr (where idet outputs its stats)
stderr, err := cmd.StderrPipe()
if err != nil {
return nil, fmt.Errorf("failed to get stderr pipe: %w", err)
}
if err := cmd.Start(); err != nil {
return nil, fmt.Errorf("failed to start ffmpeg: %w", err)
}
// Parse idet output from stderr
result := &DetectionResult{}
scanner := bufio.NewScanner(stderr)
// Regex patterns for idet statistics
// Example: [Parsed_idet_0 @ 0x...] Multi frame detection: TFF:123 BFF:0 Progressive:456 Undetermined:7
multiFrameRE := regexp.MustCompile(`Multi frame detection:\s+TFF:\s*(\d+)\s+BFF:\s*(\d+)\s+Progressive:\s*(\d+)\s+Undetermined:\s*(\d+)`)
for scanner.Scan() {
line := scanner.Text()
// Look for the final "Multi frame detection" line
if matches := multiFrameRE.FindStringSubmatch(line); matches != nil {
result.TFF, _ = strconv.Atoi(matches[1])
result.BFF, _ = strconv.Atoi(matches[2])
result.Progressive, _ = strconv.Atoi(matches[3])
result.Undetermined, _ = strconv.Atoi(matches[4])
}
}
if err := cmd.Wait(); err != nil {
// FFmpeg might return error even on success with null output
// Only fail if we got no results
if result.TFF == 0 && result.BFF == 0 && result.Progressive == 0 {
return nil, fmt.Errorf("ffmpeg failed: %w", err)
}
}
// Calculate metrics
result.TotalFrames = result.TFF + result.BFF + result.Progressive + result.Undetermined
if result.TotalFrames == 0 {
return nil, fmt.Errorf("no frames analyzed - check video file")
}
interlacedFrames := result.TFF + result.BFF
result.InterlacedPercent = (float64(interlacedFrames) / float64(result.TotalFrames)) * 100
// Determine status
if result.InterlacedPercent < 5 {
result.Status = "Progressive"
} else if result.InterlacedPercent > 95 {
result.Status = "Interlaced"
} else {
result.Status = "Mixed Content"
}
// Determine field order
if result.TFF > result.BFF*2 {
result.FieldOrder = "TFF (Top Field First)"
} else if result.BFF > result.TFF*2 {
result.FieldOrder = "BFF (Bottom Field First)"
} else if interlacedFrames > 0 {
result.FieldOrder = "Mixed/Unknown"
} else {
result.FieldOrder = "N/A (Progressive)"
}
// Determine confidence
uncertainRatio := float64(result.Undetermined) / float64(result.TotalFrames)
if uncertainRatio < 0.05 {
result.Confidence = "High"
} else if uncertainRatio < 0.15 {
result.Confidence = "Medium"
} else {
result.Confidence = "Low"
}
// Generate recommendation
if result.InterlacedPercent < 5 {
result.Recommendation = "Video is progressive. No deinterlacing needed."
result.SuggestDeinterlace = false
} else if result.InterlacedPercent > 95 {
result.Recommendation = "Video is fully interlaced. Deinterlacing strongly recommended."
result.SuggestDeinterlace = true
result.SuggestedFilter = "yadif"
} else {
result.Recommendation = fmt.Sprintf("Video has %.1f%% interlaced frames. Deinterlacing recommended for mixed content.", result.InterlacedPercent)
result.SuggestDeinterlace = true
result.SuggestedFilter = "yadif"
}
return result, nil
}
// QuickAnalyze performs a fast analysis using only the first N frames
func (d *Detector) QuickAnalyze(ctx context.Context, videoPath string) (*DetectionResult, error) {
// Analyze first 500 frames for speed
return d.Analyze(ctx, videoPath, 500)
}
// GenerateDeinterlacePreview generates a preview frame showing before/after deinterlacing
func (d *Detector) GenerateDeinterlacePreview(ctx context.Context, videoPath string, timestamp float64, outputPath string) error {
// Extract frame at timestamp, apply yadif filter, and save
args := []string{
"-ss", fmt.Sprintf("%.2f", timestamp),
"-i", videoPath,
"-vf", "yadif=0:-1:0", // Deinterlace with yadif
"-frames:v", "1",
"-y",
outputPath,
}
cmd := exec.CommandContext(ctx, d.FFmpegPath, args...)
if err := cmd.Run(); err != nil {
return fmt.Errorf("failed to generate preview: %w", err)
}
return nil
}
// GenerateComparisonPreview generates a side-by-side comparison of original vs deinterlaced
func (d *Detector) GenerateComparisonPreview(ctx context.Context, videoPath string, timestamp float64, outputPath string) error {
// Create side-by-side comparison: original (left) vs deinterlaced (right)
args := []string{
"-ss", fmt.Sprintf("%.2f", timestamp),
"-i", videoPath,
"-filter_complex", "[0:v]split=2[orig][deint];[deint]yadif=0:-1:0[d];[orig][d]hstack",
"-frames:v", "1",
"-y",
outputPath,
}
cmd := exec.CommandContext(ctx, d.FFmpegPath, args...)
if err := cmd.Run(); err != nil {
return fmt.Errorf("failed to generate comparison: %w", err)
}
return nil
}
// String returns a formatted string representation of the detection result
func (r *DetectionResult) String() string {
var sb strings.Builder
sb.WriteString(fmt.Sprintf("Status: %s\n", r.Status))
sb.WriteString(fmt.Sprintf("Interlaced: %.1f%%\n", r.InterlacedPercent))
sb.WriteString(fmt.Sprintf("Field Order: %s\n", r.FieldOrder))
sb.WriteString(fmt.Sprintf("Confidence: %s\n", r.Confidence))
sb.WriteString(fmt.Sprintf("\nFrame Analysis:\n"))
sb.WriteString(fmt.Sprintf(" Progressive: %d\n", r.Progressive))
sb.WriteString(fmt.Sprintf(" Top Field First: %d\n", r.TFF))
sb.WriteString(fmt.Sprintf(" Bottom Field First: %d\n", r.BFF))
sb.WriteString(fmt.Sprintf(" Undetermined: %d\n", r.Undetermined))
sb.WriteString(fmt.Sprintf(" Total Analyzed: %d\n", r.TotalFrames))
sb.WriteString(fmt.Sprintf("\nRecommendation: %s\n", r.Recommendation))
return sb.String()
}