conversion_project/core/black_frame_detector.py
2026-08-20 10:51:14 -04:00

215 lines
8.9 KiB
Python

# core/black_frame_detector.py
"""Black frame detection for encoding quality monitoring."""
import re
import time
from threading import Thread, Event
from pathlib import Path
from core.logger_helper import setup_logger
logger = setup_logger(Path(__file__).parent.parent / "logs")
class BlackFrameDetectionException(Exception):
"""Exception raised when persistent black frames are detected."""
pass
class BlackFrameDetector:
"""
Monitors FFmpeg output for black frames and stops encoding if detected.
NOTE: This is a real-time monitor only. The actual black frame detection
that stops encodes happens post-encoding via BlackFrameAnalyzer.
Checks for persistent black frames in the first 5 minutes of encoding.
Uses very conservative thresholds to avoid false positives from legitimate
black scenes, fades, credits, and transitions.
"""
def __init__(self, duration_seconds: int = 300, black_threshold: float = 0.95,
check_interval: int = 30, stop_callback=None):
"""
Initialize black frame detector.
Args:
duration_seconds: Time window to check for black frames (default 5 minutes = 300s)
black_threshold: Percentage of frame that must be black to count as black (0-1, default 0.95 = 95%)
check_interval: Minimum seconds between checks (default 30s)
stop_callback: Optional callable to stop the encoding process (receives process object)
"""
self.duration_seconds = duration_seconds
self.black_threshold = black_threshold
self.check_interval = check_interval
self.stop_callback = stop_callback
self.black_frame_count = 0
self.total_frames_checked = 0
self.encoding_started = False
self.start_time = None
self.last_check_time = None
self.stop_requested = Event()
self.detection_complete = Event()
def process_ffmpeg_line(self, line: str) -> bool:
"""
Process a single line from FFmpeg output.
Args:
line: A line from FFmpeg stdout/stderr
Returns:
bool: True if encoding should continue, False if should stop
"""
if not line.strip():
return True
# Look for frame processing indicator (frame= means encoding is happening)
if "frame=" in line:
if not self.encoding_started:
self.encoding_started = True
self.start_time = time.time()
logger.debug("Black frame detection: Encoding started, monitoring frames...")
# Extract time from output (format: time=HH:MM:SS.mm)
time_match = re.search(r'time=(\d+):(\d+):([\d.]+)', line)
if time_match:
hours = int(time_match.group(1))
minutes = int(time_match.group(2))
seconds = float(time_match.group(3))
current_time = hours * 3600 + minutes * 60 + seconds
# Check if we're past the detection window
if current_time > self.duration_seconds:
logger.info(f"Black frame detection: Monitoring window complete ({current_time:.0f}s > {self.duration_seconds}s). No persistent black detected.")
self.detection_complete.set()
return True # Continue encoding - we've passed the detection window
# Periodic check (avoid checking too frequently)
now = time.time()
if self.last_check_time is None or (now - self.last_check_time) >= self.check_interval:
self.last_check_time = now
# Log progress
logger.debug(f"Black frame detection: Monitoring at {current_time:.0f}s of {self.duration_seconds}s")
# Check for blackdetect filter output (used in secondary analysis)
# Format: [blackdetect @ ...] black_start:X black_end:Y black_duration:Z
if "blackdetect" in line and "black_start" in line:
logger.debug(f"Black frame detected in output: {line}")
self.black_frame_count += 1
# Extract black duration if available
duration_match = re.search(r'black_duration:([\d.]+)', line)
if duration_match:
black_duration = float(duration_match.group(1))
# If black persists for more than 2 seconds, consider it significant
if black_duration > 2.0:
logger.warning(f"Persistent black detected: {black_duration:.1f} seconds")
self.black_frame_count += 1
return True
def detect_persistent_black_in_first_frames(self, process) -> bool:
"""
Monitor encoding for persistent black frames.
This is called after encoding completes to verify output wasn't entirely black.
Uses ffmpeg's blackdetect filter to analyze the output file.
Args:
process: The FFmpeg process object (for termination)
Returns:
bool: True if black frames detected (should skip file), False if OK
"""
# This would require analyzing the output file after encoding
# For now, we rely on real-time monitoring
return self.black_frame_count > 5
def should_stop_encoding(self) -> bool:
"""Check if encoding should be stopped due to black frame detection."""
return self.stop_requested.is_set()
def signal_stop(self):
"""Signal that encoding should stop."""
self.stop_requested.set()
logger.warning("Black frame detection: STOP signal set - encoding will be terminated")
class BlackFrameAnalyzer:
"""
Analyzes output file to detect if entire video is black.
Used as post-encoding verification.
"""
@staticmethod
def analyze_output_for_black(output_file: Path, sample_duration: int = 300) -> bool:
"""
Check if output video is entirely/almost entirely black frames.
Analyzes the first 5 minutes and calculates what percentage of frames are black.
Only flags as corrupted if >95% of the frames are black (indicating encoding failure).
Args:
output_file: Path to the encoded output file
sample_duration: Duration in seconds to sample (default 300 = 5 minutes)
Returns:
bool: True if >95% of frames are black, False otherwise
"""
import subprocess
import re
if not output_file.exists():
logger.warning(f"Output file not found for black detection: {output_file}")
return False
try:
# Use ffmpeg's blackdetect filter with very sensitive settings
# This will catch even slightly darkened frames
cmd = [
"ffmpeg", "-i", str(output_file),
"-t", str(sample_duration),
"-vf", "blackdetect=d=0.01:pic_th=0.95", # Any 0.01s where >95% pixels are black
"-f", "null",
"-"
]
logger.debug(f"Running black frame analysis on {output_file.name} (first {sample_duration}s)...")
process = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
bufsize=1
)
total_black_duration = 0.0
for line in process.stdout:
# Parse blackdetect output: [blackdetect @ ...] black_start:X black_end:Y black_duration:Z
if "blackdetect" in line and "black_duration" in line:
duration_match = re.search(r'black_duration:([\d.]+)', line)
if duration_match:
duration = float(duration_match.group(1))
total_black_duration += duration
logger.debug(f"Black frame segment: {duration:.2f}s")
process.wait()
# Calculate percentage of video that is black
black_percentage = (total_black_duration / sample_duration) * 100
logger.info(f"Black frame analysis: {black_percentage:.1f}% of first {sample_duration}s is black")
# Only flag as corrupted if >95% of the video is black frames
if black_percentage > 95.0:
logger.warning(f"Output file is {black_percentage:.1f}% black frames - ENCODING FAILURE")
return True
else:
logger.info(f"Output file black content acceptable ({black_percentage:.1f}% black)")
return False
except Exception as e:
logger.warning(f"Error analyzing output for black frames: {e}")
return False