Files

361 lines
11 KiB
Python

"""
Video processing utilities for Eric Semantic Search
Handles video frame extraction, thumbnail generation, and metadata.
Uses OpenCV for video processing.
"""
import os
import logging
from pathlib import Path
from typing import List, Optional, Tuple, Dict, Any
from dataclasses import dataclass
import numpy as np
from PIL import Image
from .config import (
SUPPORTED_VIDEO_EXTENSIONS,
VIDEO_DEFAULT_FPS,
VIDEO_MAX_FRAMES,
VIDEO_MIN_FRAMES,
)
logger = logging.getLogger(__name__)
# Try to import cv2 (opencv-python)
try:
import cv2
CV2_AVAILABLE = True
except ImportError:
CV2_AVAILABLE = False
logger.warning("OpenCV not available. Video support disabled. Install with: pip install opencv-python")
@dataclass
class VideoInfo:
"""Metadata about a video file"""
path: str
width: int
height: int
fps: float
frame_count: int
duration_seconds: float
codec: str
def to_dict(self) -> Dict[str, Any]:
return {
"path": self.path,
"width": self.width,
"height": self.height,
"fps": self.fps,
"frame_count": self.frame_count,
"duration_seconds": self.duration_seconds,
"codec": self.codec,
}
def is_video_file(file_path: str) -> bool:
"""Check if file is a supported video format"""
ext = Path(file_path).suffix.lower()
return ext in SUPPORTED_VIDEO_EXTENSIONS
def get_video_info(video_path: str) -> Optional[VideoInfo]:
"""
Get metadata about a video file.
Args:
video_path: Path to video file
Returns:
VideoInfo object or None if file cannot be read
"""
if not CV2_AVAILABLE:
logger.error("OpenCV not available for video processing")
return None
try:
cap = cv2.VideoCapture(str(video_path))
if not cap.isOpened():
logger.error(f"Cannot open video: {video_path}")
return None
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)
frame_count = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
fourcc = int(cap.get(cv2.CAP_PROP_FOURCC))
codec = "".join([chr((fourcc >> 8 * i) & 0xFF) for i in range(4)])
duration = frame_count / fps if fps > 0 else 0
cap.release()
return VideoInfo(
path=str(video_path),
width=width,
height=height,
fps=fps,
frame_count=frame_count,
duration_seconds=duration,
codec=codec,
)
except Exception as e:
logger.error(f"Error getting video info for {video_path}: {e}")
return None
def extract_frames(
video_path: str,
target_fps: float = VIDEO_DEFAULT_FPS,
max_frames: int = VIDEO_MAX_FRAMES,
min_frames: int = VIDEO_MIN_FRAMES,
uniform_sampling: bool = True,
) -> List[Image.Image]:
"""
Extract frames from a video file.
Args:
video_path: Path to video file
target_fps: Target frames per second to extract (for time-based sampling)
max_frames: Maximum number of frames to extract
min_frames: Minimum number of frames (pad with last frame if needed)
uniform_sampling: If True, sample frames uniformly; if False, use target_fps
Returns:
List of PIL Images
"""
if not CV2_AVAILABLE:
logger.error("OpenCV not available for video processing")
return []
try:
cap = cv2.VideoCapture(str(video_path))
if not cap.isOpened():
logger.error(f"Cannot open video: {video_path}")
return []
video_fps = cap.get(cv2.CAP_PROP_FPS)
total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
if total_frames <= 0 or video_fps <= 0:
logger.error(f"Invalid video metadata: {video_path}")
cap.release()
return []
# Determine which frame indices to extract
if uniform_sampling:
# Uniformly sample across video duration
num_frames = min(max_frames, total_frames)
if num_frames < min_frames:
num_frames = min(min_frames, total_frames)
frame_indices = np.linspace(0, total_frames - 1, num_frames, dtype=int)
else:
# Sample at target_fps
frame_interval = max(1, int(video_fps / target_fps))
frame_indices = list(range(0, total_frames, frame_interval))[:max_frames]
frames = []
last_frame = None
for idx in frame_indices:
cap.set(cv2.CAP_PROP_POS_FRAMES, idx)
ret, frame = cap.read()
if ret:
# Convert BGR to RGB
frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
pil_image = Image.fromarray(frame_rgb)
frames.append(pil_image)
last_frame = pil_image
elif last_frame is not None:
# Use last successful frame as fallback
frames.append(last_frame)
cap.release()
# Pad with last frame if needed
while len(frames) < min_frames and last_frame is not None:
frames.append(last_frame)
return frames
except Exception as e:
logger.error(f"Error extracting frames from {video_path}: {e}")
return []
def extract_keyframe(
video_path: str,
position: float = 0.1,
) -> Optional[Image.Image]:
"""
Extract a single keyframe from a video for thumbnail.
Args:
video_path: Path to video file
position: Position in video (0.0 = start, 1.0 = end), default 10%
Returns:
PIL Image or None
"""
if not CV2_AVAILABLE:
logger.error("OpenCV not available for video processing")
return None
try:
cap = cv2.VideoCapture(str(video_path))
if not cap.isOpened():
logger.error(f"Cannot open video: {video_path}")
return None
total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
target_frame = int(total_frames * position)
target_frame = max(0, min(target_frame, total_frames - 1))
cap.set(cv2.CAP_PROP_POS_FRAMES, target_frame)
ret, frame = cap.read()
cap.release()
if ret:
frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
return Image.fromarray(frame_rgb)
return None
except Exception as e:
logger.error(f"Error extracting keyframe from {video_path}: {e}")
return None
def create_video_thumbnail(
video_path: str,
output_path: str,
max_size: int = 768,
position: float = 0.1,
quality: int = 85,
add_play_indicator: bool = True,
) -> Optional[str]:
"""
Create a thumbnail image from a video file.
Args:
video_path: Path to video file
output_path: Where to save the thumbnail
max_size: Maximum dimension (preserves aspect ratio)
position: Position in video to extract (0.0-1.0)
quality: JPEG quality
add_play_indicator: If True, add a play button overlay
Returns:
Path to saved thumbnail or None
"""
frame = extract_keyframe(video_path, position)
if frame is None:
return None
# Resize to max_size
width, height = frame.size
if width > max_size or height > max_size:
if width > height:
new_width = max_size
new_height = int(height * (max_size / width))
else:
new_height = max_size
new_width = int(width * (max_size / height))
frame = frame.resize((new_width, new_height), Image.Resampling.LANCZOS)
# Optionally add play button indicator
if add_play_indicator:
frame = _add_play_indicator(frame)
# Save
try:
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
frame.save(output_path, "JPEG", quality=quality)
return output_path
except Exception as e:
logger.error(f"Error saving video thumbnail: {e}")
return None
def _add_play_indicator(image: Image.Image, size_ratio: float = 0.15) -> Image.Image:
"""
Add a semi-transparent play button indicator to an image.
Args:
image: PIL Image to modify
size_ratio: Size of play button relative to image
Returns:
Modified image with play button
"""
try:
from PIL import ImageDraw
# Create a copy to avoid modifying original
img = image.copy()
if img.mode != 'RGBA':
img = img.convert('RGBA')
width, height = img.size
# Create overlay for the play button
overlay = Image.new('RGBA', img.size, (0, 0, 0, 0))
draw = ImageDraw.Draw(overlay)
# Calculate play button size and position
button_size = int(min(width, height) * size_ratio)
center_x = width // 2
center_y = height // 2
# Draw semi-transparent circle background
circle_radius = button_size
draw.ellipse(
[center_x - circle_radius, center_y - circle_radius,
center_x + circle_radius, center_y + circle_radius],
fill=(0, 0, 0, 128)
)
# Draw play triangle
triangle_size = button_size * 0.6
# Triangle points (pointing right)
points = [
(center_x - triangle_size * 0.4, center_y - triangle_size * 0.6),
(center_x - triangle_size * 0.4, center_y + triangle_size * 0.6),
(center_x + triangle_size * 0.6, center_y),
]
draw.polygon(points, fill=(255, 255, 255, 200))
# Composite
result = Image.alpha_composite(img, overlay)
return result.convert('RGB')
except Exception as e:
logger.warning(f"Could not add play indicator: {e}")
return image
def frames_to_video_input(frames: List[Image.Image]) -> List[Image.Image]:
"""
Prepare frames for Qwen3-VL video input format.
The Qwen3-VL model expects video as a list of PIL Images.
This function ensures they're in the correct format.
Args:
frames: List of PIL Images
Returns:
List of PIL Images ready for model input
"""
processed = []
for frame in frames:
# Ensure RGB mode
if frame.mode != 'RGB':
frame = frame.convert('RGB')
processed.append(frame)
return processed