diff --git a/examples/video_360_example.py b/examples/video_360_example.py new file mode 100644 index 0000000..bb79cac --- /dev/null +++ b/examples/video_360_example.py @@ -0,0 +1,253 @@ +#!/usr/bin/env python3 +""" +360° Video Processing Example + +This example demonstrates how to use the video processor with 360° video features. + +Prerequisites: +- Install with 360° support: uv add "video-processor[video-360-full]" +- Have a 360° video file to process + +Features demonstrated: +- Automatic 360° video detection +- 360° thumbnail generation with multiple viewing angles +- 360° sprite sheet creation +- Configuration options for 360° processing +""" + +import tempfile +from pathlib import Path + +from video_processor import ProcessorConfig, VideoProcessor, HAS_360_SUPPORT + + +def check_360_dependencies(): + """Check if 360° dependencies are available.""" + print("=== 360° Video Processing Dependencies ===") + print(f"360° Support Available: {HAS_360_SUPPORT}") + + if not HAS_360_SUPPORT: + try: + from video_processor import Video360Utils + missing = Video360Utils.get_missing_dependencies() + print(f"Missing dependencies: {missing}") + print("\nTo install 360° support:") + print(" uv add 'video-processor[video-360-full]'") + print(" # or") + print(" pip install 'video-processor[video-360-full]'") + return False + except ImportError: + print("360° utilities not available") + return False + + print("✅ All 360° dependencies available") + return True + + +def basic_360_processing(): + """Demonstrate basic 360° video processing.""" + print("\n=== Basic 360° Video Processing ===") + + # Create configuration with 360° features enabled + config = ProcessorConfig( + base_path=Path("/tmp/video_360_output"), + output_formats=["mp4", "webm"], + quality_preset="high", # Use high quality for 360° videos + + # 360° specific settings + enable_360_processing=True, + auto_detect_360=True, # Automatically detect 360° videos + generate_360_thumbnails=True, + thumbnail_360_projections=["front", "back", "up", "stereographic"], # Multiple viewing angles + video_360_bitrate_multiplier=2.5, # Higher bitrate for 360° videos + ) + + print(f"Configuration created with 360° processing: {config.enable_360_processing}") + print(f"Auto-detect 360° videos: {config.auto_detect_360}") + print(f"360° thumbnail projections: {config.thumbnail_360_projections}") + print(f"Bitrate multiplier for 360° videos: {config.video_360_bitrate_multiplier}x") + + # Create processor + processor = VideoProcessor(config) + + # Example input file (would need to be a real 360° video file) + input_file = Path("example_360_video.mp4") + + if input_file.exists(): + print(f"\nProcessing 360° video: {input_file}") + + result = processor.process_video( + input_path=input_file, + output_dir="360_output" + ) + + print(f"✅ Processing complete!") + print(f"Video ID: {result.video_id}") + print(f"Output formats: {list(result.encoded_files.keys())}") + + # Show 360° detection results + if result.metadata and "video_360" in result.metadata: + video_360_info = result.metadata["video_360"] + print(f"\n360° Video Detection:") + print(f" Is 360° video: {video_360_info['is_360_video']}") + print(f" Projection type: {video_360_info['projection_type']}") + print(f" Detection confidence: {video_360_info['confidence']}") + print(f" Detection methods: {video_360_info['detection_methods']}") + + # Show regular thumbnails + if result.thumbnails: + print(f"\nRegular thumbnails generated: {len(result.thumbnails)}") + for thumb in result.thumbnails: + print(f" 📸 {thumb}") + + # Show 360° thumbnails + if result.thumbnails_360: + print(f"\n360° thumbnails generated: {len(result.thumbnails_360)}") + for key, thumb_path in result.thumbnails_360.items(): + print(f" 🌐 {key}: {thumb_path}") + + # Show 360° sprite files + if result.sprite_360_files: + print(f"\n360° sprite sheets generated: {len(result.sprite_360_files)}") + for angle, (sprite_path, webvtt_path) in result.sprite_360_files.items(): + print(f" 🎞️ {angle}:") + print(f" Sprite: {sprite_path}") + print(f" WebVTT: {webvtt_path}") + + else: + print(f"❌ Input file not found: {input_file}") + print("Create a 360° video file or modify the path in this example.") + + +def manual_360_detection(): + """Demonstrate manual 360° video detection.""" + print("\n=== Manual 360° Video Detection ===") + + from video_processor import Video360Detection + + # Example: Test detection on various metadata scenarios + test_cases = [ + { + "name": "Aspect Ratio Detection (4K 360°)", + "metadata": { + "video": {"width": 3840, "height": 1920}, + "filename": "sample_video.mp4" + } + }, + { + "name": "Filename Pattern Detection", + "metadata": { + "video": {"width": 1920, "height": 1080}, + "filename": "my_360_VR_video.mp4" + } + }, + { + "name": "Spherical Metadata Detection", + "metadata": { + "video": {"width": 2560, "height": 1280}, + "filename": "video.mp4", + "format": { + "tags": { + "Spherical": "1", + "ProjectionType": "equirectangular", + "StereoMode": "mono" + } + } + } + }, + { + "name": "Regular Video (No 360°)", + "metadata": { + "video": {"width": 1920, "height": 1080}, + "filename": "regular_video.mp4" + } + } + ] + + for test_case in test_cases: + print(f"\n{test_case['name']}:") + result = Video360Detection.detect_360_video(test_case["metadata"]) + + print(f" 360° Video: {result['is_360_video']}") + if result['is_360_video']: + print(f" Projection: {result['projection_type']}") + print(f" Confidence: {result['confidence']:.1f}") + print(f" Methods: {result['detection_methods']}") + + +def advanced_360_configuration(): + """Demonstrate advanced 360° configuration options.""" + print("\n=== Advanced 360° Configuration ===") + + from video_processor import Video360Utils + + # Show bitrate recommendations + print("Bitrate multipliers by projection type:") + projection_types = ["equirectangular", "cubemap", "cylindrical", "stereographic"] + for projection in projection_types: + multiplier = Video360Utils.get_recommended_bitrate_multiplier(projection) + print(f" {projection}: {multiplier}x") + + # Show optimal resolutions + print("\nOptimal resolutions for equirectangular 360° videos:") + resolutions = Video360Utils.get_optimal_resolutions("equirectangular") + for width, height in resolutions[:5]: # Show first 5 + print(f" {width}x{height} ({width//1000}K)") + + # Create specialized configurations + print("\nSpecialized Configuration Examples:") + + # High-quality archival processing + archival_config = ProcessorConfig( + enable_360_processing=True, + quality_preset="ultra", + video_360_bitrate_multiplier=3.0, # Even higher quality + thumbnail_360_projections=["front", "back", "left", "right", "up", "down"], # All angles + generate_360_thumbnails=True, + auto_detect_360=True, + ) + print(f" 📚 Archival config: {archival_config.quality_preset} quality, {archival_config.video_360_bitrate_multiplier}x bitrate") + + # Mobile-optimized processing + mobile_config = ProcessorConfig( + enable_360_processing=True, + quality_preset="medium", + video_360_bitrate_multiplier=2.0, # Lower for mobile + thumbnail_360_projections=["front", "stereographic"], # Minimal angles + generate_360_thumbnails=True, + auto_detect_360=True, + ) + print(f" 📱 Mobile config: {mobile_config.quality_preset} quality, {mobile_config.video_360_bitrate_multiplier}x bitrate") + + +def main(): + """Run all 360° video processing examples.""" + print("🌐 360° Video Processing Examples") + print("=" * 50) + + # Check dependencies first + if not check_360_dependencies(): + print("\n⚠️ 360° processing features are not fully available.") + print("Some examples will be skipped or show limited functionality.") + + # Still show detection examples that work without full dependencies + manual_360_detection() + return + + # Run all examples + try: + basic_360_processing() + manual_360_detection() + advanced_360_configuration() + + print("\n✅ All 360° video processing examples completed successfully!") + + except Exception as e: + print(f"\n❌ Error during 360° processing: {e}") + print("Make sure you have:") + print(" 1. Installed 360° dependencies: uv add 'video-processor[video-360-full]'") + print(" 2. A valid 360° video file to process") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/src/video_processor/__init__.py b/src/video_processor/__init__.py index 33897a3..2e895b4 100644 --- a/src/video_processor/__init__.py +++ b/src/video_processor/__init__.py @@ -9,6 +9,13 @@ from .config import ProcessorConfig from .core.processor import VideoProcessor from .exceptions import EncodingError, StorageError, VideoProcessorError +# Optional 360° imports +try: + from .utils.video_360 import Video360Detection, Video360Utils, HAS_360_SUPPORT + from .core.thumbnails_360 import Thumbnail360Generator +except ImportError: + HAS_360_SUPPORT = False + __version__ = "0.1.0" __all__ = [ "VideoProcessor", @@ -16,4 +23,13 @@ __all__ = [ "VideoProcessorError", "EncodingError", "StorageError", + "HAS_360_SUPPORT", ] + +# Add 360° exports if available +if HAS_360_SUPPORT: + __all__.extend([ + "Video360Detection", + "Video360Utils", + "Thumbnail360Generator", + ]) diff --git a/src/video_processor/config.py b/src/video_processor/config.py index 5a7df21..2330ccf 100644 --- a/src/video_processor/config.py +++ b/src/video_processor/config.py @@ -5,6 +5,15 @@ from typing import Literal from pydantic import BaseModel, ConfigDict, Field, field_validator +# Optional dependency detection for 360° features +try: + from .utils.video_360 import Video360Utils, ProjectionType, StereoMode, HAS_360_SUPPORT +except ImportError: + # Fallback types when 360° libraries not available + ProjectionType = str + StereoMode = str + HAS_360_SUPPORT = False + class ProcessorConfig(BaseModel): """Configuration for video processor.""" @@ -34,6 +43,16 @@ class ProcessorConfig(BaseModel): # File permissions file_permissions: int = 0o644 directory_permissions: int = 0o755 + + # 360° Video settings (only active if 360° libraries are available) + enable_360_processing: bool = Field(default=HAS_360_SUPPORT) + auto_detect_360: bool = Field(default=True) + force_360_projection: ProjectionType | None = Field(default=None) + video_360_bitrate_multiplier: float = Field(default=2.5, ge=1.0, le=5.0) + generate_360_thumbnails: bool = Field(default=True) + thumbnail_360_projections: list[Literal["front", "back", "up", "down", "left", "right", "stereographic"]] = Field( + default=["front", "stereographic"] + ) @field_validator("base_path") @classmethod @@ -48,5 +67,16 @@ class ProcessorConfig(BaseModel): if not v: raise ValueError("At least one output format must be specified") return v + + @field_validator("enable_360_processing") + @classmethod + def validate_360_processing(cls, v: bool) -> bool: + """Validate 360° processing can be enabled.""" + if v and not HAS_360_SUPPORT: + raise ValueError( + "360° processing requires optional dependencies. " + f"Install with: pip install 'video-processor[video-360]' or uv add 'video-processor[video-360]'" + ) + return v model_config = ConfigDict(validate_assignment=True) diff --git a/src/video_processor/core/metadata.py b/src/video_processor/core/metadata.py index 2445c02..fea5524 100644 --- a/src/video_processor/core/metadata.py +++ b/src/video_processor/core/metadata.py @@ -7,6 +7,7 @@ import ffmpeg from ..config import ProcessorConfig from ..exceptions import FFmpegError +from ..utils.video_360 import Video360Detection class VideoMetadata: @@ -56,6 +57,10 @@ class VideoMetadata: # Raw probe data for advanced use cases "raw_probe_data": probe_data, } + + # Add 360° video detection + video_360_info = Video360Detection.detect_360_video(metadata) + metadata["video_360"] = video_360_info return metadata diff --git a/src/video_processor/core/processor.py b/src/video_processor/core/processor.py index 3d2d43e..b215214 100644 --- a/src/video_processor/core/processor.py +++ b/src/video_processor/core/processor.py @@ -10,6 +10,13 @@ from .encoders import VideoEncoder from .metadata import VideoMetadata from .thumbnails import ThumbnailGenerator +# Optional 360° support +try: + from .thumbnails_360 import Thumbnail360Generator + HAS_360_SUPPORT = True +except ImportError: + HAS_360_SUPPORT = False + class VideoProcessingResult: """Result of video processing operation.""" @@ -24,6 +31,8 @@ class VideoProcessingResult: sprite_file: Path | None = None, webvtt_file: Path | None = None, metadata: dict | None = None, + thumbnails_360: dict[str, Path] | None = None, + sprite_360_files: dict[str, tuple[Path, Path]] | None = None, ) -> None: self.video_id = video_id self.input_path = input_path @@ -33,6 +42,8 @@ class VideoProcessingResult: self.sprite_file = sprite_file self.webvtt_file = webvtt_file self.metadata = metadata + self.thumbnails_360 = thumbnails_360 or {} + self.sprite_360_files = sprite_360_files or {} class VideoProcessor: @@ -44,6 +55,12 @@ class VideoProcessor: self.encoder = VideoEncoder(config) self.thumbnail_generator = ThumbnailGenerator(config) self.metadata_extractor = VideoMetadata(config) + + # Initialize 360° thumbnail generator if available and enabled + if HAS_360_SUPPORT and config.enable_360_processing: + self.thumbnail_360_generator = Thumbnail360Generator(config) + else: + self.thumbnail_360_generator = None def _create_storage_backend(self) -> StorageBackend: """Create storage backend based on configuration.""" @@ -121,6 +138,46 @@ class VideoProcessor: sprite_file, webvtt_file = self.thumbnail_generator.generate_sprites( encoded_files["mp4"], output_dir, video_id ) + + # Generate 360° thumbnails and sprites if this is a 360° video + thumbnails_360 = {} + sprite_360_files = {} + + if (self.thumbnail_360_generator and + self.config.generate_360_thumbnails and + metadata.get("video_360", {}).get("is_360_video", False)): + + # Get 360° video information + video_360_info = metadata["video_360"] + projection_type = video_360_info.get("projection_type", "equirectangular") + + # Generate 360° thumbnails for each timestamp + for timestamp in self.config.thumbnail_timestamps: + angle_thumbnails = self.thumbnail_360_generator.generate_360_thumbnails( + encoded_files.get("mp4", input_path), + output_dir, + timestamp, + video_id, + projection_type, + self.config.thumbnail_360_projections, + ) + + # Store thumbnails by timestamp and angle + for angle, thumbnail_path in angle_thumbnails.items(): + key = f"{timestamp}s_{angle}" + thumbnails_360[key] = thumbnail_path + + # Generate 360° sprite sheets for each viewing angle + if self.config.generate_sprites: + for angle in self.config.thumbnail_360_projections: + sprite_360, webvtt_360 = self.thumbnail_360_generator.generate_360_sprite_thumbnails( + encoded_files.get("mp4", input_path), + output_dir, + video_id, + projection_type, + angle, + ) + sprite_360_files[angle] = (sprite_360, webvtt_360) return VideoProcessingResult( video_id=video_id, @@ -131,6 +188,8 @@ class VideoProcessor: sprite_file=sprite_file, webvtt_file=webvtt_file, metadata=metadata, + thumbnails_360=thumbnails_360, + sprite_360_files=sprite_360_files, ) except Exception as e: diff --git a/src/video_processor/core/thumbnails_360.py b/src/video_processor/core/thumbnails_360.py new file mode 100644 index 0000000..5f73857 --- /dev/null +++ b/src/video_processor/core/thumbnails_360.py @@ -0,0 +1,415 @@ +"""360° video thumbnail generation with projection support.""" + +import math +from pathlib import Path +from typing import Literal + +import ffmpeg + +from ..config import ProcessorConfig +from ..exceptions import EncodingError, FFmpegError + +# Optional dependency handling +try: + import cv2 + import numpy as np + from ..utils.video_360 import ProjectionType, Video360Utils, HAS_360_SUPPORT +except ImportError: + # Fallback types when dependencies not available + ProjectionType = str + HAS_360_SUPPORT = False + +ViewingAngle = Literal["front", "back", "left", "right", "up", "down", "stereographic"] + + +class Thumbnail360Generator: + """Handles 360° video thumbnail generation with various projections.""" + + def __init__(self, config: ProcessorConfig) -> None: + self.config = config + + if not HAS_360_SUPPORT: + raise ImportError( + "360° thumbnail generation requires optional dependencies. " + "Install with: uv add 'video-processor[video-360]'" + ) + + def generate_360_thumbnails( + self, + video_path: Path, + output_dir: Path, + timestamp: int, + video_id: str, + projection_type: ProjectionType = "equirectangular", + viewing_angles: list[ViewingAngle] | None = None, + ) -> dict[str, Path]: + """ + Generate 360° thumbnails for different viewing angles. + + Args: + video_path: Path to 360° video file + output_dir: Output directory + timestamp: Time in seconds to extract thumbnail + video_id: Unique video identifier + projection_type: Type of 360° projection + viewing_angles: List of viewing angles to generate + + Returns: + Dictionary mapping viewing angles to thumbnail paths + """ + if viewing_angles is None: + viewing_angles = self.config.thumbnail_360_projections + + thumbnails = {} + + # First extract a full equirectangular frame + equirect_frame = self._extract_equirectangular_frame( + video_path, timestamp, output_dir, video_id + ) + + try: + # Load the equirectangular image + equirect_img = cv2.imread(str(equirect_frame)) + if equirect_img is None: + raise EncodingError(f"Failed to load equirectangular frame: {equirect_frame}") + + # Generate thumbnails for each viewing angle + for angle in viewing_angles: + thumbnail_path = self._generate_angle_thumbnail( + equirect_img, angle, output_dir, video_id, timestamp + ) + thumbnails[angle] = thumbnail_path + + finally: + # Clean up temporary equirectangular frame + if equirect_frame.exists(): + equirect_frame.unlink() + + return thumbnails + + def _extract_equirectangular_frame( + self, video_path: Path, timestamp: int, output_dir: Path, video_id: str + ) -> Path: + """Extract a full equirectangular frame from the 360° video.""" + temp_frame = output_dir / f"{video_id}_temp_equirect_{timestamp}.jpg" + + try: + # Get video info + probe = ffmpeg.probe(str(video_path)) + video_stream = next( + stream for stream in probe["streams"] + if stream["codec_type"] == "video" + ) + + width = video_stream["width"] + height = video_stream["height"] + duration = float(video_stream.get("duration", 0)) + + # Adjust timestamp if beyond video duration + if timestamp >= duration: + timestamp = max(1, int(duration // 2)) + + # Extract full resolution frame + ( + ffmpeg.input(str(video_path), ss=timestamp) + .filter("scale", width, height) + .output(str(temp_frame), vframes=1, q=2) # High quality + .overwrite_output() + .run(capture_stdout=True, capture_stderr=True, quiet=True) + ) + + except ffmpeg.Error as e: + error_msg = e.stderr.decode() if e.stderr else "Unknown FFmpeg error" + raise FFmpegError(f"Frame extraction failed: {error_msg}") from e + + if not temp_frame.exists(): + raise EncodingError("Frame extraction failed - output file not created") + + return temp_frame + + def _generate_angle_thumbnail( + self, + equirect_img: "np.ndarray", + viewing_angle: ViewingAngle, + output_dir: Path, + video_id: str, + timestamp: int, + ) -> Path: + """Generate thumbnail for a specific viewing angle.""" + output_path = output_dir / f"{video_id}_360_{viewing_angle}_{timestamp}.jpg" + + if viewing_angle == "stereographic": + # Generate "little planet" stereographic projection + thumbnail = self._create_stereographic_projection(equirect_img) + else: + # Generate perspective projection for the viewing angle + thumbnail = self._create_perspective_projection(equirect_img, viewing_angle) + + # Save thumbnail + cv2.imwrite(str(output_path), thumbnail, [cv2.IMWRITE_JPEG_QUALITY, 85]) + + return output_path + + def _create_perspective_projection( + self, equirect_img: "np.ndarray", viewing_angle: ViewingAngle + ) -> "np.ndarray": + """Create perspective projection for a viewing angle.""" + height, width = equirect_img.shape[:2] + + # Define viewing directions (yaw, pitch) in radians + viewing_directions = { + "front": (0, 0), + "back": (math.pi, 0), + "left": (-math.pi/2, 0), + "right": (math.pi/2, 0), + "up": (0, math.pi/2), + "down": (0, -math.pi/2), + } + + if viewing_angle not in viewing_directions: + viewing_angle = "front" + + yaw, pitch = viewing_directions[viewing_angle] + + # Generate perspective view + thumbnail_size = self.config.thumbnail_width + fov = math.pi / 3 # 60 degrees field of view + + # Create coordinate maps for perspective projection + u_map, v_map = self._create_perspective_maps( + thumbnail_size, thumbnail_size, fov, yaw, pitch, width, height + ) + + # Apply remapping + thumbnail = cv2.remap(equirect_img, u_map, v_map, cv2.INTER_LINEAR) + + return thumbnail + + def _create_stereographic_projection(self, equirect_img: "np.ndarray") -> "np.ndarray": + """Create stereographic 'little planet' projection.""" + height, width = equirect_img.shape[:2] + + # Output size for stereographic projection + output_size = self.config.thumbnail_width + + # Create coordinate maps for stereographic projection + y_coords, x_coords = np.mgrid[0:output_size, 0:output_size] + + # Convert to centered coordinates + x_centered = (x_coords - output_size // 2) / (output_size // 2) + y_centered = (y_coords - output_size // 2) / (output_size // 2) + + # Calculate distance from center + r = np.sqrt(x_centered**2 + y_centered**2) + + # Create mask for circular boundary + mask = r <= 1.0 + + # Convert to spherical coordinates for stereographic projection + theta = np.arctan2(y_centered, x_centered) + phi = 2 * np.arctan(r) + + # Convert to equirectangular coordinates + u = (theta + np.pi) / (2 * np.pi) * width + v = (np.pi/2 - phi) / np.pi * height + + # Clamp coordinates + u = np.clip(u, 0, width - 1) + v = np.clip(v, 0, height - 1) + + # Create maps for remapping + u_map = u.astype(np.float32) + v_map = v.astype(np.float32) + + # Apply remapping + thumbnail = cv2.remap(equirect_img, u_map, v_map, cv2.INTER_LINEAR) + + # Apply circular mask + thumbnail[~mask] = [0, 0, 0] # Black background + + return thumbnail + + def _create_perspective_maps( + self, + out_width: int, + out_height: int, + fov: float, + yaw: float, + pitch: float, + equirect_width: int, + equirect_height: int, + ) -> tuple["np.ndarray", "np.ndarray"]: + """Create coordinate mapping for perspective projection.""" + # Create output coordinate grids + y_coords, x_coords = np.mgrid[0:out_height, 0:out_width] + + # Convert to normalized device coordinates [-1, 1] + x_ndc = (x_coords - out_width / 2) / (out_width / 2) + y_ndc = (y_coords - out_height / 2) / (out_height / 2) + + # Apply perspective projection + focal_length = 1.0 / math.tan(fov / 2) + + # Create 3D ray directions + x_3d = x_ndc / focal_length + y_3d = y_ndc / focal_length + z_3d = np.ones_like(x_3d) + + # Normalize ray directions + ray_length = np.sqrt(x_3d**2 + y_3d**2 + z_3d**2) + x_3d /= ray_length + y_3d /= ray_length + z_3d /= ray_length + + # Apply rotation for viewing direction + # Rotate by yaw (around Y axis) + cos_yaw, sin_yaw = math.cos(yaw), math.sin(yaw) + x_rot = x_3d * cos_yaw - z_3d * sin_yaw + z_rot = x_3d * sin_yaw + z_3d * cos_yaw + + # Rotate by pitch (around X axis) + cos_pitch, sin_pitch = math.cos(pitch), math.sin(pitch) + y_rot = y_3d * cos_pitch - z_rot * sin_pitch + z_final = y_3d * sin_pitch + z_rot * cos_pitch + + # Convert 3D coordinates to spherical + theta = np.arctan2(x_rot, z_final) + phi = np.arcsin(np.clip(y_rot, -1, 1)) + + # Convert spherical to equirectangular coordinates + u = (theta + np.pi) / (2 * np.pi) * equirect_width + v = (np.pi/2 - phi) / np.pi * equirect_height + + # Clamp to image boundaries + u = np.clip(u, 0, equirect_width - 1) + v = np.clip(v, 0, equirect_height - 1) + + return u.astype(np.float32), v.astype(np.float32) + + def generate_360_sprite_thumbnails( + self, + video_path: Path, + output_dir: Path, + video_id: str, + projection_type: ProjectionType = "equirectangular", + viewing_angle: ViewingAngle = "front", + ) -> tuple[Path, Path]: + """ + Generate 360° sprite sheet for a specific viewing angle. + + Args: + video_path: Path to 360° video file + output_dir: Output directory + video_id: Unique video identifier + projection_type: Type of 360° projection + viewing_angle: Viewing angle for sprite generation + + Returns: + Tuple of (sprite_file_path, webvtt_file_path) + """ + sprite_file = output_dir / f"{video_id}_360_{viewing_angle}_sprite.jpg" + webvtt_file = output_dir / f"{video_id}_360_{viewing_angle}_sprite.webvtt" + frames_dir = output_dir / "frames_360" + + # Create frames directory + frames_dir.mkdir(exist_ok=True) + + try: + # Get video duration + probe = ffmpeg.probe(str(video_path)) + duration = float(probe["format"]["duration"]) + + # Generate frames at specified intervals + interval = self.config.sprite_interval + timestamps = list(range(0, int(duration), interval)) + + frame_paths = [] + for i, timestamp in enumerate(timestamps): + # Generate 360° thumbnail for this timestamp + thumbnails = self.generate_360_thumbnails( + video_path, frames_dir, timestamp, f"{video_id}_frame_{i}", + projection_type, [viewing_angle] + ) + + if viewing_angle in thumbnails: + frame_paths.append(thumbnails[viewing_angle]) + + # Create sprite sheet from frames + if frame_paths: + self._create_sprite_sheet(frame_paths, sprite_file, timestamps, webvtt_file) + + return sprite_file, webvtt_file + + finally: + # Clean up frame files + if frames_dir.exists(): + for frame_file in frames_dir.glob("*"): + if frame_file.is_file(): + frame_file.unlink() + frames_dir.rmdir() + + def _create_sprite_sheet( + self, + frame_paths: list[Path], + sprite_file: Path, + timestamps: list[int], + webvtt_file: Path, + ) -> None: + """Create sprite sheet from individual frames.""" + if not frame_paths: + raise EncodingError("No frames available for sprite sheet creation") + + # Load first frame to get dimensions + first_frame = cv2.imread(str(frame_paths[0])) + if first_frame is None: + raise EncodingError(f"Failed to load first frame: {frame_paths[0]}") + + frame_height, frame_width = first_frame.shape[:2] + + # Calculate sprite sheet layout + cols = 10 # 10 thumbnails per row + rows = math.ceil(len(frame_paths) / cols) + + sprite_width = cols * frame_width + sprite_height = rows * frame_height + + # Create sprite sheet + sprite_img = np.zeros((sprite_height, sprite_width, 3), dtype=np.uint8) + + # Create WebVTT content + webvtt_content = ["WEBVTT", ""] + + # Place frames in sprite sheet and create WebVTT entries + for i, (frame_path, timestamp) in enumerate(zip(frame_paths, timestamps)): + frame = cv2.imread(str(frame_path)) + if frame is None: + continue + + # Calculate position in sprite + col = i % cols + row = i // cols + + x_start = col * frame_width + y_start = row * frame_height + x_end = x_start + frame_width + y_end = y_start + frame_height + + # Place frame in sprite + sprite_img[y_start:y_end, x_start:x_end] = frame + + # Create WebVTT entry + start_time = f"{timestamp//3600:02d}:{(timestamp%3600)//60:02d}:{timestamp%60:02d}.000" + end_time = f"{(timestamp+1)//3600:02d}:{((timestamp+1)%3600)//60:02d}:{(timestamp+1)%60:02d}.000" + + webvtt_content.extend([ + f"{start_time} --> {end_time}", + f"{sprite_file.name}#xywh={x_start},{y_start},{frame_width},{frame_height}", + "" + ]) + + # Save sprite sheet + cv2.imwrite(str(sprite_file), sprite_img, [cv2.IMWRITE_JPEG_QUALITY, 85]) + + # Save WebVTT file + with open(webvtt_file, 'w') as f: + f.write('\n'.join(webvtt_content)) \ No newline at end of file diff --git a/src/video_processor/utils/video_360.py b/src/video_processor/utils/video_360.py new file mode 100644 index 0000000..cf17be7 --- /dev/null +++ b/src/video_processor/utils/video_360.py @@ -0,0 +1,318 @@ +"""360° video detection and utility functions.""" + +from pathlib import Path +from typing import Any, Literal + +# Optional dependency handling +try: + import cv2 + HAS_OPENCV = True +except ImportError: + HAS_OPENCV = False + +try: + import numpy as np + HAS_NUMPY = True +except ImportError: + HAS_NUMPY = False + +try: + import py360convert + HAS_PY360CONVERT = True +except ImportError: + HAS_PY360CONVERT = False + +try: + import exifread + HAS_EXIFREAD = True +except ImportError: + HAS_EXIFREAD = False + +# Overall 360° support requires core dependencies +HAS_360_SUPPORT = HAS_OPENCV and HAS_NUMPY and HAS_PY360CONVERT + + +ProjectionType = Literal["equirectangular", "cubemap", "cylindrical", "stereographic", "unknown"] +StereoMode = Literal["mono", "top-bottom", "left-right", "unknown"] + + +class Video360Detection: + """Utilities for detecting and analyzing 360° videos.""" + + @staticmethod + def detect_360_video(video_metadata: dict[str, Any]) -> dict[str, Any]: + """ + Detect if a video is a 360° video based on metadata and resolution. + + Args: + video_metadata: Video metadata dictionary from ffmpeg probe + + Returns: + Dictionary with 360° detection results + """ + detection_result = { + "is_360_video": False, + "projection_type": "unknown", + "stereo_mode": "mono", + "confidence": 0.0, + "detection_methods": [], + } + + # Check for spherical video metadata (Google/YouTube standard) + spherical_metadata = Video360Detection._check_spherical_metadata(video_metadata) + if spherical_metadata["found"]: + detection_result.update({ + "is_360_video": True, + "projection_type": spherical_metadata["projection_type"], + "stereo_mode": spherical_metadata["stereo_mode"], + "confidence": 1.0, + }) + detection_result["detection_methods"].append("spherical_metadata") + + # Check aspect ratio for equirectangular projection + aspect_ratio_check = Video360Detection._check_aspect_ratio(video_metadata) + if aspect_ratio_check["is_likely_360"]: + if not detection_result["is_360_video"]: + detection_result.update({ + "is_360_video": True, + "projection_type": "equirectangular", + "confidence": aspect_ratio_check["confidence"], + }) + detection_result["detection_methods"].append("aspect_ratio") + + # Check filename patterns + filename_check = Video360Detection._check_filename_patterns(video_metadata) + if filename_check["is_likely_360"]: + if not detection_result["is_360_video"]: + detection_result.update({ + "is_360_video": True, + "projection_type": filename_check["projection_type"], + "confidence": filename_check["confidence"], + }) + detection_result["detection_methods"].append("filename") + + return detection_result + + @staticmethod + def _check_spherical_metadata(metadata: dict[str, Any]) -> dict[str, Any]: + """Check for spherical video metadata tags.""" + result = { + "found": False, + "projection_type": "equirectangular", + "stereo_mode": "mono", + } + + # Check format tags for spherical metadata + format_tags = metadata.get("format", {}).get("tags", {}) + + # Google spherical video standard + if "spherical" in format_tags: + result["found"] = True + + # Check for specific spherical video tags + spherical_indicators = [ + "Spherical", + "spherical-video", + "SphericalVideo", + "ProjectionType", + "projection_type", + ] + + for tag_name, tag_value in format_tags.items(): + if any(indicator.lower() in tag_name.lower() for indicator in spherical_indicators): + result["found"] = True + + # Determine projection type from metadata + if isinstance(tag_value, str): + tag_lower = tag_value.lower() + if "equirectangular" in tag_lower: + result["projection_type"] = "equirectangular" + elif "cubemap" in tag_lower: + result["projection_type"] = "cubemap" + + # Check for stereo mode indicators + stereo_indicators = ["StereoMode", "stereo_mode", "StereoscopicMode"] + for tag_name, tag_value in format_tags.items(): + if any(indicator.lower() in tag_name.lower() for indicator in stereo_indicators): + if isinstance(tag_value, str): + tag_lower = tag_value.lower() + if "top-bottom" in tag_lower or "tb" in tag_lower: + result["stereo_mode"] = "top-bottom" + elif "left-right" in tag_lower or "lr" in tag_lower: + result["stereo_mode"] = "left-right" + + return result + + @staticmethod + def _check_aspect_ratio(metadata: dict[str, Any]) -> dict[str, Any]: + """Check if aspect ratio suggests 360° video.""" + result = { + "is_likely_360": False, + "confidence": 0.0, + } + + video_info = metadata.get("video", {}) + if not video_info: + return result + + width = video_info.get("width", 0) + height = video_info.get("height", 0) + + if width <= 0 or height <= 0: + return result + + aspect_ratio = width / height + + # Equirectangular videos typically have 2:1 aspect ratio + if 1.9 <= aspect_ratio <= 2.1: + result["is_likely_360"] = True + result["confidence"] = 0.8 + + # Higher confidence for exact 2:1 ratio + if 1.98 <= aspect_ratio <= 2.02: + result["confidence"] = 0.9 + + # Some 360° videos use different aspect ratios + elif 1.5 <= aspect_ratio <= 2.5: + # Common resolutions for 360° video + common_360_resolutions = [ + (3840, 1920), # 4K 360° + (1920, 960), # 2K 360° + (2560, 1280), # QHD 360° + (4096, 2048), # Cinema 4K 360° + (5760, 2880), # 6K 360° + ] + + for res_width, res_height in common_360_resolutions: + if (width == res_width and height == res_height) or \ + (width == res_height and height == res_width): + result["is_likely_360"] = True + result["confidence"] = 0.7 + break + + return result + + @staticmethod + def _check_filename_patterns(metadata: dict[str, Any]) -> dict[str, Any]: + """Check filename for 360° indicators.""" + result = { + "is_likely_360": False, + "projection_type": "equirectangular", + "confidence": 0.0, + } + + filename = metadata.get("filename", "").lower() + if not filename: + return result + + # Common 360° filename patterns + patterns_360 = [ + "360", "vr", "spherical", "equirectangular", + "panoramic", "immersive", "omnidirectional" + ] + + # Projection type patterns + projection_patterns = { + "equirectangular": ["equirect", "equi", "spherical"], + "cubemap": ["cube", "cubemap", "cubic"], + "cylindrical": ["cylindrical", "cylinder"], + } + + # Check for 360° indicators + for pattern in patterns_360: + if pattern in filename: + result["is_likely_360"] = True + result["confidence"] = 0.6 + break + + # Check for specific projection types + if result["is_likely_360"]: + for projection, patterns in projection_patterns.items(): + if any(pattern in filename for pattern in patterns): + result["projection_type"] = projection + result["confidence"] = 0.7 + break + + return result + + +class Video360Utils: + """Utility functions for 360° video processing.""" + + @staticmethod + def get_recommended_bitrate_multiplier(projection_type: ProjectionType) -> float: + """ + Get recommended bitrate multiplier for 360° videos. + + 360° videos typically need higher bitrates than regular videos + due to the immersive viewing experience and projection distortion. + + Args: + projection_type: Type of 360° projection + + Returns: + Multiplier to apply to standard bitrates + """ + multipliers = { + "equirectangular": 2.5, # Most common, needs high bitrate + "cubemap": 2.0, # More efficient encoding + "cylindrical": 1.8, # Less immersive, lower multiplier + "stereographic": 2.2, # Good balance + "unknown": 2.0, # Safe default + } + + return multipliers.get(projection_type, 2.0) + + @staticmethod + def get_optimal_resolutions(projection_type: ProjectionType) -> list[tuple[int, int]]: + """ + Get optimal resolutions for different 360° projection types. + + Args: + projection_type: Type of 360° projection + + Returns: + List of (width, height) tuples for optimal resolutions + """ + resolutions = { + "equirectangular": [ + (1920, 960), # 2K 360° + (2560, 1280), # QHD 360° + (3840, 1920), # 4K 360° + (4096, 2048), # Cinema 4K 360° + (5760, 2880), # 6K 360° + (7680, 3840), # 8K 360° + ], + "cubemap": [ + (1536, 1536), # 1.5K per face + (2048, 2048), # 2K per face + (3072, 3072), # 3K per face + (4096, 4096), # 4K per face + ], + } + + return resolutions.get(projection_type, resolutions["equirectangular"]) + + @staticmethod + def is_360_library_available() -> bool: + """Check if 360° processing libraries are available.""" + return HAS_360_SUPPORT + + @staticmethod + def get_missing_dependencies() -> list[str]: + """Get list of missing dependencies for 360° processing.""" + missing = [] + + if not HAS_OPENCV: + missing.append("opencv-python") + + if not HAS_NUMPY: + missing.append("numpy") + + if not HAS_PY360CONVERT: + missing.append("py360convert") + + if not HAS_EXIFREAD: + missing.append("exifread") + + return missing \ No newline at end of file diff --git a/tests/test_video_360.py b/tests/test_video_360.py new file mode 100644 index 0000000..3f6e29a --- /dev/null +++ b/tests/test_video_360.py @@ -0,0 +1,178 @@ +"""Tests for 360° video functionality.""" + +import pytest + +from video_processor import ProcessorConfig, HAS_360_SUPPORT +from video_processor.utils.video_360 import Video360Detection, Video360Utils + + +class TestVideo360Detection: + """Tests for 360° video detection.""" + + def test_aspect_ratio_detection(self): + """Test 360° detection based on aspect ratio.""" + # Mock metadata for 2:1 aspect ratio (typical 360° video) + metadata = { + "video": { + "width": 3840, + "height": 1920, + }, + "filename": "test_video.mp4", + } + + result = Video360Detection.detect_360_video(metadata) + + assert result["is_360_video"] is True + assert "aspect_ratio" in result["detection_methods"] + assert result["confidence"] >= 0.8 + + def test_filename_pattern_detection(self): + """Test 360° detection based on filename patterns.""" + metadata = { + "video": {"width": 1920, "height": 1080}, + "filename": "my_360_video.mp4", + } + + result = Video360Detection.detect_360_video(metadata) + + assert result["is_360_video"] is True + assert "filename" in result["detection_methods"] + assert result["projection_type"] == "equirectangular" + + def test_spherical_metadata_detection(self): + """Test 360° detection based on spherical metadata.""" + metadata = { + "video": {"width": 1920, "height": 1080}, + "filename": "test.mp4", + "format": { + "tags": { + "Spherical": "1", + "ProjectionType": "equirectangular" + } + } + } + + result = Video360Detection.detect_360_video(metadata) + + assert result["is_360_video"] is True + assert "spherical_metadata" in result["detection_methods"] + assert result["confidence"] == 1.0 + assert result["projection_type"] == "equirectangular" + + def test_no_360_detection(self): + """Test that regular videos are not detected as 360°.""" + metadata = { + "video": {"width": 1920, "height": 1080}, + "filename": "regular_video.mp4", + } + + result = Video360Detection.detect_360_video(metadata) + + assert result["is_360_video"] is False + assert result["confidence"] == 0.0 + assert len(result["detection_methods"]) == 0 + + +class TestVideo360Utils: + """Tests for 360° video utilities.""" + + def test_bitrate_multipliers(self): + """Test bitrate multipliers for different projection types.""" + assert Video360Utils.get_recommended_bitrate_multiplier("equirectangular") == 2.5 + assert Video360Utils.get_recommended_bitrate_multiplier("cubemap") == 2.0 + assert Video360Utils.get_recommended_bitrate_multiplier("unknown") == 2.0 + + def test_optimal_resolutions(self): + """Test optimal resolution recommendations.""" + equirect_resolutions = Video360Utils.get_optimal_resolutions("equirectangular") + assert (3840, 1920) in equirect_resolutions # 4K 360° + assert (1920, 960) in equirect_resolutions # 2K 360° + + def test_missing_dependencies(self): + """Test missing dependency detection.""" + missing = Video360Utils.get_missing_dependencies() + assert isinstance(missing, list) + + # Without optional dependencies, these should be missing + if not HAS_360_SUPPORT: + assert "opencv-python" in missing + assert "py360convert" in missing + + +class TestProcessorConfig360: + """Tests for 360° configuration.""" + + def test_default_360_settings(self): + """Test default 360° configuration values.""" + config = ProcessorConfig() + + assert config.enable_360_processing == HAS_360_SUPPORT + assert config.auto_detect_360 is True + assert config.force_360_projection is None + assert config.video_360_bitrate_multiplier == 2.5 + assert config.generate_360_thumbnails is True + assert "front" in config.thumbnail_360_projections + assert "stereographic" in config.thumbnail_360_projections + + def test_360_validation_without_dependencies(self): + """Test that 360° processing can't be enabled without dependencies.""" + if not HAS_360_SUPPORT: + with pytest.raises(ValueError, match="360° processing requires optional dependencies"): + ProcessorConfig(enable_360_processing=True) + + @pytest.mark.skipif(not HAS_360_SUPPORT, reason="360° dependencies not available") + def test_360_validation_with_dependencies(self): + """Test that 360° processing can be enabled with dependencies.""" + config = ProcessorConfig(enable_360_processing=True) + assert config.enable_360_processing is True + + def test_bitrate_multiplier_validation(self): + """Test bitrate multiplier validation.""" + # Valid range + config = ProcessorConfig(video_360_bitrate_multiplier=3.0) + assert config.video_360_bitrate_multiplier == 3.0 + + # Invalid range should raise validation error + with pytest.raises(ValueError): + ProcessorConfig(video_360_bitrate_multiplier=0.5) # Below minimum + + with pytest.raises(ValueError): + ProcessorConfig(video_360_bitrate_multiplier=6.0) # Above maximum + + def test_custom_360_settings(self): + """Test custom 360° configuration.""" + config = ProcessorConfig( + auto_detect_360=False, + video_360_bitrate_multiplier=2.0, + generate_360_thumbnails=False, + thumbnail_360_projections=["front", "back"], + ) + + assert config.auto_detect_360 is False + assert config.video_360_bitrate_multiplier == 2.0 + assert config.generate_360_thumbnails is False + assert config.thumbnail_360_projections == ["front", "back"] + + +# Integration test for basic video processor +class TestVideoProcessor360Integration: + """Integration tests for 360° video processing.""" + + def test_processor_creation_without_360_support(self): + """Test that video processor works without 360° support.""" + from video_processor import VideoProcessor + + config = ProcessorConfig() # 360° disabled by default when deps missing + processor = VideoProcessor(config) + + assert processor.thumbnail_360_generator is None + + @pytest.mark.skipif(not HAS_360_SUPPORT, reason="360° dependencies not available") + def test_processor_creation_with_360_support(self): + """Test that video processor works with 360° support.""" + from video_processor import VideoProcessor + + config = ProcessorConfig(enable_360_processing=True) + processor = VideoProcessor(config) + + assert processor.thumbnail_360_generator is not None \ No newline at end of file