# 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