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.
This commit is contained in:
18
usda_vision_system/video/presentation/__init__.py
Normal file
18
usda_vision_system/video/presentation/__init__.py
Normal file
@@ -0,0 +1,18 @@
|
||||
"""
|
||||
Video Presentation Layer.
|
||||
|
||||
Contains HTTP controllers, request/response models, and API route definitions.
|
||||
"""
|
||||
|
||||
from .controllers import VideoController, StreamingController
|
||||
from .schemas import VideoInfoResponse, VideoListResponse, StreamingInfoResponse
|
||||
from .routes import create_video_routes
|
||||
|
||||
__all__ = [
|
||||
"VideoController",
|
||||
"StreamingController",
|
||||
"VideoInfoResponse",
|
||||
"VideoListResponse",
|
||||
"StreamingInfoResponse",
|
||||
"create_video_routes",
|
||||
]
|
||||
207
usda_vision_system/video/presentation/controllers.py
Normal file
207
usda_vision_system/video/presentation/controllers.py
Normal file
@@ -0,0 +1,207 @@
|
||||
"""
|
||||
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")
|
||||
167
usda_vision_system/video/presentation/routes.py
Normal file
167
usda_vision_system/video/presentation/routes.py
Normal file
@@ -0,0 +1,167 @@
|
||||
"""
|
||||
Video API Routes.
|
||||
|
||||
FastAPI route definitions for video streaming and management.
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, Request
|
||||
from fastapi.responses import Response
|
||||
|
||||
from .controllers import VideoController, StreamingController
|
||||
from .schemas import (
|
||||
VideoInfoResponse, VideoListResponse, VideoListRequest,
|
||||
StreamingInfoResponse, ThumbnailRequest
|
||||
)
|
||||
|
||||
|
||||
def create_video_routes(
|
||||
video_controller: VideoController,
|
||||
streaming_controller: StreamingController
|
||||
) -> APIRouter:
|
||||
"""Create video API routes with dependency injection"""
|
||||
|
||||
router = APIRouter(prefix="/videos", tags=["videos"])
|
||||
|
||||
@router.get("/", response_model=VideoListResponse)
|
||||
async def list_videos(
|
||||
camera_name: Optional[str] = Query(None, description="Filter by camera name"),
|
||||
start_date: Optional[datetime] = Query(None, description="Filter by start date"),
|
||||
end_date: Optional[datetime] = Query(None, description="Filter by end date"),
|
||||
limit: Optional[int] = Query(50, description="Maximum number of results"),
|
||||
include_metadata: bool = Query(False, description="Include video metadata")
|
||||
):
|
||||
"""
|
||||
List videos with optional filters.
|
||||
|
||||
- **camera_name**: Filter videos by camera name
|
||||
- **start_date**: Filter videos created after this date
|
||||
- **end_date**: Filter videos created before this date
|
||||
- **limit**: Maximum number of videos to return
|
||||
- **include_metadata**: Whether to include video metadata (duration, resolution, etc.)
|
||||
"""
|
||||
request = VideoListRequest(
|
||||
camera_name=camera_name,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
limit=limit,
|
||||
include_metadata=include_metadata
|
||||
)
|
||||
return await video_controller.list_videos(request)
|
||||
|
||||
@router.get("/{file_id}", response_model=VideoInfoResponse)
|
||||
async def get_video_info(file_id: str):
|
||||
"""
|
||||
Get detailed information about a specific video.
|
||||
|
||||
- **file_id**: Unique identifier for the video file
|
||||
"""
|
||||
return await video_controller.get_video_info(file_id)
|
||||
|
||||
@router.get("/{file_id}/stream")
|
||||
async def stream_video(file_id: str, request: Request):
|
||||
"""
|
||||
Stream video with HTTP range request support.
|
||||
|
||||
Supports:
|
||||
- **Range requests**: For seeking and progressive download
|
||||
- **Partial content**: 206 responses for range requests
|
||||
- **Format conversion**: Automatic conversion to web-compatible formats
|
||||
- **Caching**: Intelligent caching for better performance
|
||||
|
||||
Usage in HTML5:
|
||||
```html
|
||||
<video controls>
|
||||
<source src="/videos/{file_id}/stream" type="video/mp4">
|
||||
</video>
|
||||
```
|
||||
"""
|
||||
return await streaming_controller.stream_video(file_id, request)
|
||||
|
||||
@router.get("/{file_id}/info", response_model=StreamingInfoResponse)
|
||||
async def get_streaming_info(file_id: str):
|
||||
"""
|
||||
Get streaming information for a video.
|
||||
|
||||
Returns technical details needed for optimal streaming:
|
||||
- File size and content type
|
||||
- Range request support
|
||||
- Recommended chunk size
|
||||
"""
|
||||
return await streaming_controller.get_streaming_info(file_id)
|
||||
|
||||
@router.get("/{file_id}/thumbnail")
|
||||
async def get_video_thumbnail(
|
||||
file_id: str,
|
||||
timestamp: float = Query(1.0, description="Timestamp in seconds to extract thumbnail from"),
|
||||
width: int = Query(320, description="Thumbnail width in pixels"),
|
||||
height: int = Query(240, description="Thumbnail height in pixels")
|
||||
):
|
||||
"""
|
||||
Generate and return a thumbnail image from the video.
|
||||
|
||||
- **file_id**: Video file identifier
|
||||
- **timestamp**: Time position in seconds to extract thumbnail from
|
||||
- **width**: Thumbnail width in pixels
|
||||
- **height**: Thumbnail height in pixels
|
||||
|
||||
Returns JPEG image data.
|
||||
"""
|
||||
thumbnail_request = ThumbnailRequest(
|
||||
timestamp_seconds=timestamp,
|
||||
width=width,
|
||||
height=height
|
||||
)
|
||||
return await video_controller.get_video_thumbnail(file_id, thumbnail_request)
|
||||
|
||||
@router.post("/{file_id}/validate")
|
||||
async def validate_video(file_id: str):
|
||||
"""
|
||||
Validate that a video file is accessible and playable.
|
||||
|
||||
- **file_id**: Video file identifier
|
||||
|
||||
Returns validation status and any issues found.
|
||||
"""
|
||||
return await video_controller.validate_video(file_id)
|
||||
|
||||
@router.post("/{file_id}/cache/invalidate")
|
||||
async def invalidate_video_cache(file_id: str):
|
||||
"""
|
||||
Invalidate cached data for a video file.
|
||||
|
||||
Useful when a video file has been updated or replaced.
|
||||
|
||||
- **file_id**: Video file identifier
|
||||
"""
|
||||
return await streaming_controller.invalidate_cache(file_id)
|
||||
|
||||
return router
|
||||
|
||||
|
||||
def create_admin_video_routes(streaming_controller: StreamingController) -> APIRouter:
|
||||
"""Create admin routes for video management"""
|
||||
|
||||
router = APIRouter(prefix="/admin/videos", tags=["admin", "videos"])
|
||||
|
||||
@router.post("/cache/cleanup")
|
||||
async def cleanup_video_cache(
|
||||
max_size_mb: int = Query(100, description="Maximum cache size in MB")
|
||||
):
|
||||
"""
|
||||
Clean up video streaming cache.
|
||||
|
||||
Removes old cached data to keep cache size under the specified limit.
|
||||
|
||||
- **max_size_mb**: Maximum cache size to maintain
|
||||
"""
|
||||
entries_removed = await streaming_controller.streaming_service.cleanup_cache(max_size_mb)
|
||||
return {
|
||||
"cache_cleaned": True,
|
||||
"entries_removed": entries_removed,
|
||||
"max_size_mb": max_size_mb
|
||||
}
|
||||
|
||||
return router
|
||||
138
usda_vision_system/video/presentation/schemas.py
Normal file
138
usda_vision_system/video/presentation/schemas.py
Normal file
@@ -0,0 +1,138 @@
|
||||
"""
|
||||
Video API Request/Response Schemas.
|
||||
|
||||
Pydantic models for API serialization and validation.
|
||||
"""
|
||||
|
||||
from typing import List, Optional, Tuple
|
||||
from datetime import datetime
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class VideoMetadataResponse(BaseModel):
|
||||
"""Video metadata response model"""
|
||||
duration_seconds: float = Field(..., description="Video duration in seconds")
|
||||
width: int = Field(..., description="Video width in pixels")
|
||||
height: int = Field(..., description="Video height in pixels")
|
||||
fps: float = Field(..., description="Video frame rate")
|
||||
codec: str = Field(..., description="Video codec")
|
||||
bitrate: Optional[int] = Field(None, description="Video bitrate in bps")
|
||||
aspect_ratio: float = Field(..., description="Video aspect ratio")
|
||||
|
||||
class Config:
|
||||
schema_extra = {
|
||||
"example": {
|
||||
"duration_seconds": 120.5,
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
"fps": 30.0,
|
||||
"codec": "XVID",
|
||||
"bitrate": 5000000,
|
||||
"aspect_ratio": 1.777
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class VideoInfoResponse(BaseModel):
|
||||
"""Video file information response"""
|
||||
file_id: str = Field(..., description="Unique file identifier")
|
||||
camera_name: str = Field(..., description="Camera that recorded the video")
|
||||
filename: str = Field(..., description="Original filename")
|
||||
file_size_bytes: int = Field(..., description="File size in bytes")
|
||||
format: str = Field(..., description="Video format (avi, mp4, webm)")
|
||||
status: str = Field(..., description="Video status")
|
||||
created_at: datetime = Field(..., description="Creation timestamp")
|
||||
start_time: Optional[datetime] = Field(None, description="Recording start time")
|
||||
end_time: Optional[datetime] = Field(None, description="Recording end time")
|
||||
machine_trigger: Optional[str] = Field(None, description="Machine that triggered recording")
|
||||
metadata: Optional[VideoMetadataResponse] = Field(None, description="Video metadata")
|
||||
is_streamable: bool = Field(..., description="Whether video can be streamed")
|
||||
needs_conversion: bool = Field(..., description="Whether video needs format conversion")
|
||||
|
||||
class Config:
|
||||
schema_extra = {
|
||||
"example": {
|
||||
"file_id": "camera1_recording_20250804_143022.avi",
|
||||
"camera_name": "camera1",
|
||||
"filename": "camera1_recording_20250804_143022.avi",
|
||||
"file_size_bytes": 52428800,
|
||||
"format": "avi",
|
||||
"status": "completed",
|
||||
"created_at": "2025-08-04T14:30:22",
|
||||
"start_time": "2025-08-04T14:30:22",
|
||||
"end_time": "2025-08-04T14:32:22",
|
||||
"machine_trigger": "vibratory_conveyor",
|
||||
"is_streamable": True,
|
||||
"needs_conversion": True
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class VideoListResponse(BaseModel):
|
||||
"""Video list response"""
|
||||
videos: List[VideoInfoResponse] = Field(..., description="List of videos")
|
||||
total_count: int = Field(..., description="Total number of videos")
|
||||
|
||||
class Config:
|
||||
schema_extra = {
|
||||
"example": {
|
||||
"videos": [],
|
||||
"total_count": 0
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class StreamingInfoResponse(BaseModel):
|
||||
"""Streaming information response"""
|
||||
file_id: str = Field(..., description="Video file ID")
|
||||
file_size_bytes: int = Field(..., description="Total file size")
|
||||
content_type: str = Field(..., description="MIME content type")
|
||||
supports_range_requests: bool = Field(..., description="Whether range requests are supported")
|
||||
chunk_size_bytes: int = Field(..., description="Recommended chunk size for streaming")
|
||||
|
||||
class Config:
|
||||
schema_extra = {
|
||||
"example": {
|
||||
"file_id": "camera1_recording_20250804_143022.avi",
|
||||
"file_size_bytes": 52428800,
|
||||
"content_type": "video/x-msvideo",
|
||||
"supports_range_requests": True,
|
||||
"chunk_size_bytes": 262144
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class VideoListRequest(BaseModel):
|
||||
"""Video list request parameters"""
|
||||
camera_name: Optional[str] = Field(None, description="Filter by camera name")
|
||||
start_date: Optional[datetime] = Field(None, description="Filter by start date")
|
||||
end_date: Optional[datetime] = Field(None, description="Filter by end date")
|
||||
limit: Optional[int] = Field(50, description="Maximum number of results")
|
||||
include_metadata: bool = Field(False, description="Include video metadata")
|
||||
|
||||
class Config:
|
||||
schema_extra = {
|
||||
"example": {
|
||||
"camera_name": "camera1",
|
||||
"start_date": "2025-08-04T00:00:00",
|
||||
"end_date": "2025-08-04T23:59:59",
|
||||
"limit": 50,
|
||||
"include_metadata": True
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class ThumbnailRequest(BaseModel):
|
||||
"""Thumbnail generation request"""
|
||||
timestamp_seconds: float = Field(1.0, description="Timestamp to extract thumbnail from")
|
||||
width: int = Field(320, description="Thumbnail width")
|
||||
height: int = Field(240, description="Thumbnail height")
|
||||
|
||||
class Config:
|
||||
schema_extra = {
|
||||
"example": {
|
||||
"timestamp_seconds": 5.0,
|
||||
"width": 320,
|
||||
"height": 240
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user