Research-Stack/4-Infrastructure/NoDupeLabs/nodupe/tools/video/__init__.py

389 lines
13 KiB
Python

"""NoDupeLabs Video Tools - Video Processing Backends
This module provides video processing backends with 5-tier graceful degradation
for frame extraction, metadata analysis, and perceptual hashing.
"""
from typing import List, Optional, Dict, Any
import logging
import subprocess
import hashlib
import numpy as np
from abc import ABC, abstractmethod
from pathlib import Path
# Configure logging
logger = logging.getLogger(__name__)
class VideoBackend(ABC):
"""Abstract base class for video backends"""
@abstractmethod
def is_available(self) -> bool:
"""Check if this backend is available"""
@abstractmethod
def extract_frames(self, video_path: str, max_frames: int = 10) -> List[np.ndarray]:
"""Extract key frames from video"""
@abstractmethod
def get_video_metadata(self, video_path: str) -> Dict[str, Any]:
"""Get video metadata (duration, resolution, fps, etc.)"""
@abstractmethod
def compute_perceptual_hash(self, frame: np.ndarray) -> str:
"""Compute perceptual hash for video frame"""
@abstractmethod
def get_priority(self) -> int:
"""Get backend priority (lower number = higher priority)"""
class FFmpegSubprocessBackend(VideoBackend):
"""Tier 5: FFmpeg CLI backend (always available if ffmpeg binary exists)"""
def __init__(self):
"""Initialize FFmpeg subprocess backend."""
self.priority = 5
self._available = self._check_ffmpeg_available()
def _check_ffmpeg_available(self) -> bool:
"""Check if ffmpeg binary is available in PATH"""
try:
subprocess.run(['ffmpeg', '-version'],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
check=True)
return True
except (subprocess.CalledProcessError, FileNotFoundError):
logger.warning("FFmpeg binary not found in PATH")
return False
def is_available(self) -> bool:
"""Check if FFmpeg backend is available"""
return self._available
def extract_frames(self, video_path: str, max_frames: int = 10) -> List[np.ndarray]:
"""Extract frames using FFmpeg CLI"""
if not self.is_available():
logger.error("FFmpeg not available")
return []
try:
# Create temporary directory for frames
temp_dir = Path("temp_frames")
temp_dir.mkdir(exist_ok=True)
# FFmpeg command to extract frames
output_pattern = str(temp_dir / "frame_%04d.png")
cmd = [
'ffmpeg',
'-i', video_path,
'-vf', f'fps=1/{max_frames},scale=256:144',
'-frames:v', str(max_frames),
output_pattern
]
subprocess.run(cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
check=True)
# Load extracted frames
frames = []
for i in range(max_frames):
frame_path = temp_dir / f"frame_{i:04d}.png"
if frame_path.exists():
try:
import cv2
frame = cv2.imread(str(frame_path))
if frame is not None:
frames.append(frame)
except ImportError:
# Fallback: use PIL if OpenCV not available
try:
from PIL import Image
frame = np.array(Image.open(frame_path))
frames.append(frame)
except ImportError:
logger.warning("Neither OpenCV nor PIL available for frame loading")
# Clean up
for f in temp_dir.glob("frame_*.png"):
f.unlink()
temp_dir.rmdir()
return frames
except Exception as e:
logger.error(f"Error extracting frames with FFmpeg: {e}")
return []
def get_video_metadata(self, video_path: str) -> Dict[str, Any]:
"""Get video metadata using FFmpeg"""
if not self.is_available():
return {}
try:
cmd = [
'ffmpeg',
'-i', video_path
]
subprocess.run(cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
check=False)
metadata = {}
stderr = result.stderr.decode('utf-8', errors='ignore')
# Parse basic metadata from FFmpeg output
for line in stderr.split('\n'):
if 'Duration:' in line:
# Parse duration
parts = line.split(',')
duration_part = parts[0].split('Duration:')[1].strip()
h, m, s = duration_part.split(':')
metadata['duration_seconds'] = float(h) * 3600 + float(m) * 60 + float(s)
elif 'Video:' in line:
# Parse video resolution and fps
if 'x' in line:
res_part = line.split('Video:')[1].split(',')[0].strip()
width, height = res_part.split('x')[:2]
metadata['width'] = int(width)
metadata['height'] = int(height)
return metadata
except Exception as e:
logger.error(f"Error getting video metadata: {e}")
return {}
def compute_perceptual_hash(self, frame: np.ndarray) -> str:
"""Compute simple perceptual hash (average hash)"""
try:
# Convert to grayscale if needed
if len(frame.shape) == 3:
gray = np.mean(frame, axis=2).astype(np.uint8)
else:
gray = frame
# Resize to small fixed size
resized = cv2.resize(gray, (8, 8)) if 'cv2' in locals() else gray[:8, :8]
# Compute average and create hash
avg = np.mean(resized)
bits = ''.join(['1' if pixel > avg else '0' for pixel in resized.flatten()])
return hashlib.md5(bits.encode()).hexdigest()
except Exception as e:
logger.error(f"Error computing perceptual hash: {e}")
return ""
def get_priority(self) -> int:
"""Get backend priority"""
return self.priority
class OpenCVBackend(VideoBackend):
"""Tier 4: OpenCV backend"""
def __init__(self):
"""Initialize FFmpeg subprocess backend."""
self.priority = 4
self._available = self._check_opencv_available()
def _check_opencv_available(self) -> bool:
"""Check if OpenCV is available"""
try:
return True
except ImportError:
logger.warning("OpenCV not available")
return False
def is_available(self) -> bool:
"""Check if OpenCV backend is available"""
return self._available
def extract_frames(self, video_path: str, max_frames: int = 10) -> List[np.ndarray]:
"""Extract frames using OpenCV"""
if not self.is_available():
logger.error("OpenCV not available")
return []
try:
import cv2
cap = cv2.VideoCapture(video_path)
if not cap.isOpened():
logger.error(f"Could not open video: {video_path}")
return []
frames = []
total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
frame_step = max(1, total_frames // max_frames)
for i in range(0, total_frames, frame_step):
cap.set(cv2.CAP_PROP_POS_FRAMES, i)
ret, frame = cap.read()
if ret:
frames.append(frame)
if len(frames) >= max_frames:
break
cap.release()
return frames
except Exception as e:
logger.error(f"Error extracting frames with OpenCV: {e}")
return []
def get_video_metadata(self, video_path: str) -> Dict[str, Any]:
"""Get video metadata using OpenCV"""
if not self.is_available():
return {}
try:
import cv2
cap = cv2.VideoCapture(video_path)
if not cap.isOpened():
return {}
metadata = {
'width': int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)),
'height': int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)),
'fps': cap.get(cv2.CAP_PROP_FPS),
'duration_seconds': cap.get(cv2.CAP_PROP_FRAME_COUNT) / max(1, cap.get(cv2.CAP_PROP_FPS)),
'frame_count': int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
}
cap.release()
return metadata
except Exception as e:
logger.error(f"Error getting video metadata: {e}")
return {}
def compute_perceptual_hash(self, frame: np.ndarray) -> str:
"""Compute perceptual hash using OpenCV"""
try:
import cv2
# Convert to grayscale
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) if len(frame.shape) == 3 else frame
# Resize and compute hash
resized = cv2.resize(gray, (8, 8))
avg = np.mean(resized)
bits = ''.join(['1' if pixel > avg else '0' for pixel in resized.flatten()])
return hashlib.md5(bits.encode()).hexdigest()
except Exception as e:
logger.error(f"Error computing perceptual hash: {e}")
return ""
def get_priority(self) -> int:
"""Get backend priority"""
return self.priority
class VideoBackendManager:
"""Manage multiple video backends with automatic fallback"""
def __init__(self):
"""Initialize FFmpeg subprocess backend."""
self.backends = []
self._initialize_backends()
def _initialize_backends(self):
"""Initialize all available backends in priority order"""
# Initialize backends in priority order (lower number = higher priority)
backends_to_try = [
('opencv', OpenCVBackend),
('ffmpeg', FFmpegSubprocessBackend)
]
for name, backend_class in backends_to_try:
try:
backend = backend_class()
if backend.is_available():
self.backends.append(backend)
logger.info(f"Initialized {name} backend (priority {backend.get_priority()})")
except Exception as e:
logger.warning(f"Failed to initialize {name} backend: {e}")
# Sort backends by priority
self.backends.sort(key=lambda x: x.get_priority())
if not self.backends:
logger.warning("No video backends available")
def extract_frames(self, video_path: str, max_frames: int = 10) -> List[np.ndarray]:
"""Extract frames using the best available backend"""
for backend in self.backends:
try:
frames = backend.extract_frames(video_path, max_frames)
if frames:
logger.info(
f"Extracted {len(frames)} frames using {backend.__class__.__name__}")
return frames
except Exception as e:
logger.warning(f"Backend {backend.__class__.__name__} failed: {e}")
continue
logger.error("All video backends failed to extract frames")
return []
def get_video_metadata(self, video_path: str) -> Dict[str, Any]:
"""Get video metadata using the best available backend"""
for backend in self.backends:
try:
metadata = backend.get_video_metadata(video_path)
if metadata:
logger.info(f"Retrieved metadata using {backend.__class__.__name__}")
return metadata
except Exception as e:
logger.warning(f"Backend {backend.__class__.__name__} failed: {e}")
continue
logger.error("All video backends failed to get metadata")
return {}
def compute_perceptual_hash(self, frame: np.ndarray) -> str:
"""Compute perceptual hash using the best available backend"""
for backend in self.backends:
try:
phash = backend.compute_perceptual_hash(frame)
if phash:
return phash
except Exception as e:
logger.warning(f"Backend {backend.__class__.__name__} failed: {e}")
continue
logger.error("All video backends failed to compute perceptual hash")
return ""
# Module-level backend manager
VIDEO_MANAGER: Optional[VideoBackendManager] = None
def get_video_backend_manager() -> VideoBackendManager:
"""Get the global video backend manager"""
global VIDEO_MANAGER
if VIDEO_MANAGER is None:
VIDEO_MANAGER = VideoBackendManager()
return VIDEO_MANAGER
# Initialize manager on import
get_video_backend_manager()
__all__ = [
'VideoBackend', 'FFmpegSubprocessBackend', 'OpenCVBackend',
'VideoBackendManager', 'get_video_backend_manager', 'register_tool'
]
from .video_plugin import register_tool