Files
usda-vision/usda_vision_system/video/presentation/controllers.py
Alireza Vaezi 37553163db Implement video processing module with FFmpeg conversion, OpenCV metadata extraction, and file system repository
- Added FFmpegVideoConverter for video format conversion using FFmpeg.
- Implemented NoOpVideoConverter for scenarios where FFmpeg is unavailable.
- Created OpenCVMetadataExtractor for extracting video metadata.
- Developed FileSystemVideoRepository for managing video files in the file system.
- Integrated video services with dependency injection in VideoModule.
- Established API routes for video management and streaming.
- Added request/response schemas for video metadata and streaming information.
- Implemented caching mechanisms for video streaming.
- Included error handling and logging throughout the module.
2025-08-04 16:44:53 -04:00

208 lines
8.0 KiB
Python

"""
Video HTTP Controllers.
Handle HTTP requests and responses for video operations.
"""
import logging
from typing import Optional
from datetime import datetime
from fastapi import HTTPException, Request, Response
from fastapi.responses import StreamingResponse
from ..application.video_service import VideoService
from ..application.streaming_service import StreamingService
from ..domain.models import StreamRange, VideoFile
from .schemas import (
VideoInfoResponse, VideoListResponse, VideoListRequest,
StreamingInfoResponse, ThumbnailRequest, VideoMetadataResponse
)
class VideoController:
"""Controller for video management operations"""
def __init__(self, video_service: VideoService):
self.video_service = video_service
self.logger = logging.getLogger(__name__)
async def get_video_info(self, file_id: str) -> VideoInfoResponse:
"""Get video information"""
video_file = await self.video_service.get_video_by_id(file_id)
if not video_file:
raise HTTPException(status_code=404, detail=f"Video {file_id} not found")
return self._convert_to_response(video_file)
async def list_videos(self, request: VideoListRequest) -> VideoListResponse:
"""List videos with optional filters"""
if request.camera_name:
videos = await self.video_service.get_videos_by_camera(
camera_name=request.camera_name,
start_date=request.start_date,
end_date=request.end_date,
limit=request.limit,
include_metadata=request.include_metadata
)
else:
videos = await self.video_service.get_all_videos(
start_date=request.start_date,
end_date=request.end_date,
limit=request.limit,
include_metadata=request.include_metadata
)
video_responses = [self._convert_to_response(video) for video in videos]
return VideoListResponse(
videos=video_responses,
total_count=len(video_responses)
)
async def get_video_thumbnail(
self,
file_id: str,
thumbnail_request: ThumbnailRequest
) -> Response:
"""Get video thumbnail"""
thumbnail_data = await self.video_service.get_video_thumbnail(
file_id=file_id,
timestamp_seconds=thumbnail_request.timestamp_seconds,
size=(thumbnail_request.width, thumbnail_request.height)
)
if not thumbnail_data:
raise HTTPException(status_code=404, detail=f"Could not generate thumbnail for {file_id}")
return Response(
content=thumbnail_data,
media_type="image/jpeg",
headers={
"Cache-Control": "public, max-age=3600", # Cache for 1 hour
"Content-Length": str(len(thumbnail_data))
}
)
async def validate_video(self, file_id: str) -> dict:
"""Validate video file"""
is_valid = await self.video_service.validate_video(file_id)
return {"file_id": file_id, "is_valid": is_valid}
def _convert_to_response(self, video_file: VideoFile) -> VideoInfoResponse:
"""Convert domain model to response model"""
metadata_response = None
if video_file.metadata:
metadata_response = VideoMetadataResponse(
duration_seconds=video_file.metadata.duration_seconds,
width=video_file.metadata.width,
height=video_file.metadata.height,
fps=video_file.metadata.fps,
codec=video_file.metadata.codec,
bitrate=video_file.metadata.bitrate,
aspect_ratio=video_file.metadata.aspect_ratio
)
return VideoInfoResponse(
file_id=video_file.file_id,
camera_name=video_file.camera_name,
filename=video_file.filename,
file_size_bytes=video_file.file_size_bytes,
format=video_file.format.value,
status=video_file.status.value,
created_at=video_file.created_at,
start_time=video_file.start_time,
end_time=video_file.end_time,
machine_trigger=video_file.machine_trigger,
metadata=metadata_response,
is_streamable=video_file.is_streamable,
needs_conversion=video_file.needs_conversion()
)
class StreamingController:
"""Controller for video streaming operations"""
def __init__(self, streaming_service: StreamingService, video_service: VideoService):
self.streaming_service = streaming_service
self.video_service = video_service
self.logger = logging.getLogger(__name__)
async def get_streaming_info(self, file_id: str) -> StreamingInfoResponse:
"""Get streaming information for a video"""
video_file = await self.streaming_service.get_video_info(file_id)
if not video_file:
raise HTTPException(status_code=404, detail=f"Video {file_id} not found")
chunk_size = await self.streaming_service.get_optimal_chunk_size(video_file.file_size_bytes)
content_type = self._get_content_type(video_file)
return StreamingInfoResponse(
file_id=file_id,
file_size_bytes=video_file.file_size_bytes,
content_type=content_type,
supports_range_requests=True,
chunk_size_bytes=chunk_size
)
async def stream_video(self, file_id: str, request: Request) -> Response:
"""Stream video with range request support"""
# Prepare video for streaming (convert if needed)
video_file = await self.video_service.prepare_for_streaming(file_id)
if not video_file:
raise HTTPException(status_code=404, detail=f"Video {file_id} not found or not streamable")
# Parse range header
range_header = request.headers.get("range")
range_request = None
if range_header:
try:
range_request = StreamRange.from_header(range_header, video_file.file_size_bytes)
except ValueError as e:
raise HTTPException(status_code=416, detail=f"Invalid range request: {e}")
# Get video data
data, _, actual_range = await self.streaming_service.stream_video_range(file_id, range_request)
if data is None:
raise HTTPException(status_code=500, detail="Failed to read video data")
# Determine response type and headers
content_type = self._get_content_type(video_file)
headers = {
"Accept-Ranges": "bytes",
"Content-Length": str(len(data)),
"Cache-Control": "public, max-age=3600"
}
# Use partial content if range was requested
if actual_range and self.streaming_service.should_use_partial_content(actual_range, video_file.file_size_bytes):
headers["Content-Range"] = self.streaming_service.calculate_content_range_header(
actual_range, video_file.file_size_bytes
)
status_code = 206 # Partial Content
else:
status_code = 200 # OK
return Response(
content=data,
status_code=status_code,
headers=headers,
media_type=content_type
)
async def invalidate_cache(self, file_id: str) -> dict:
"""Invalidate streaming cache for a video"""
success = await self.streaming_service.invalidate_cache(file_id)
return {"file_id": file_id, "cache_invalidated": success}
def _get_content_type(self, video_file: VideoFile) -> str:
"""Get MIME content type for video file"""
format_to_mime = {
"avi": "video/x-msvideo",
"mp4": "video/mp4",
"webm": "video/webm"
}
return format_to_mime.get(video_file.format.value, "application/octet-stream")