Add interlacing detection analyzer
Created core interlacing detection system using FFmpeg's idet filter. Features: - Analyze videos for interlacing using FFmpeg idet filter - Parse TFF, BFF, Progressive, and Undetermined frame counts - Calculate interlacing percentage and confidence level - Determine field order (TFF/BFF/Mixed/Progressive) - Generate recommendations for deinterlacing - Quick analysis mode (first 500 frames) for speed - Full video analysis option - Preview generation: deinterlaced frame or side-by-side comparison Detection Results include: - Status: Progressive / Interlaced / Mixed Content - Interlacing %: Portion of frames that are interlaced - Field Order: Top Field First, Bottom Field First, or Unknown - Confidence: High/Medium/Low based on undetermined frames - Recommendation: Human-readable guidance - Suggested filter: yadif, bwdif, etc. Next: UI integration in Convert and Inspect modules 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
56a0d3f39f
commit
49c865b1e3
232
internal/interlace/detector.go
Normal file
232
internal/interlace/detector.go
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
package interlace
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 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()
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user