215 lines
8.9 KiB
Python
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
|