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:
Ryan Malloy 2025-09-05 09:45:09 -06:00
parent 9c0bd90299
commit cfda5d6777
8 changed files with 1274 additions and 0 deletions

View 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()

View File

@ -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",
])

View File

@ -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."""
@ -35,6 +44,16 @@ class ProcessorConfig(BaseModel):
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
def validate_base_path(cls, v: Path) -> Path:
@ -49,4 +68,15 @@ class ProcessorConfig(BaseModel):
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)

View File

@ -7,6 +7,7 @@ import ffmpeg
from ..config import ProcessorConfig
from ..exceptions import FFmpegError
from ..utils.video_360 import Video360Detection
class VideoMetadata:
@ -57,6 +58,10 @@ class VideoMetadata:
"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
except ffmpeg.Error as e:

View File

@ -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:
@ -45,6 +56,12 @@ class VideoProcessor:
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."""
if self.config.storage_backend == "local":
@ -122,6 +139,46 @@ class VideoProcessor:
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,
input_path=input_path,
@ -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:

View 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))

View 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
View 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