Implement comprehensive 360° video processing support
Core Features: - 360° video detection via metadata, aspect ratio, and filename patterns - Automatic projection type identification (equirectangular, cubemap, etc.) - 360° thumbnail generation with multiple viewing angles (front, back, up, down, stereographic) - 360° sprite sheet creation for immersive video players - Enhanced metadata extraction with spherical video information Configuration: - Optional 360° settings in ProcessorConfig with validation - Bitrate multipliers for 360° content (typically 2.5x for quality) - Configurable thumbnail projections and generation options - Graceful degradation when optional dependencies unavailable Architecture: - Modular design with optional dependency detection - Video360Detection class for intelligent 360° identification - Thumbnail360Generator for perspective and stereographic projections - Video360Utils for bitrate/resolution recommendations - Extended VideoProcessingResult with 360° outputs Testing & Examples: - Comprehensive test suite covering detection, configuration, and integration - Working example demonstrating 360° processing workflow - Proper error handling and dependency validation Backward Compatibility: - All existing functionality preserved - 360° features completely optional and isolated - Clear error messages when dependencies missing 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
9c0bd90299
commit
cfda5d6777
253
examples/video_360_example.py
Normal file
253
examples/video_360_example.py
Normal file
@ -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()
|
@ -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",
|
||||
])
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
|
||||
|
@ -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:
|
||||
|
415
src/video_processor/core/thumbnails_360.py
Normal file
415
src/video_processor/core/thumbnails_360.py
Normal file
@ -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))
|
318
src/video_processor/utils/video_360.py
Normal file
318
src/video_processor/utils/video_360.py
Normal file
@ -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
|
178
tests/test_video_360.py
Normal file
178
tests/test_video_360.py
Normal file
@ -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
|
Loading…
x
Reference in New Issue
Block a user