Files
usda-vision/media-api/main.py
salirezav 70f614e9ff Enhance video streaming capabilities and UI integration
- Added support for streaming video files with proper MIME type handling in the media API.
- Implemented transcoding functionality for H.264 compatibility on-the-fly using FFmpeg.
- Updated VideoModal component to utilize Video.js for improved video playback experience.
- Enhanced user interface with download options and better error handling for video playback.
- Updated package.json and package-lock.json to include new dependencies for video.js and related types.
2025-10-31 18:29:05 -04:00

324 lines
11 KiB
Python

import os
import pathlib
import subprocess
import urllib.parse
from typing import List, Optional
from fastapi import FastAPI, HTTPException, Response, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, StreamingResponse
MEDIA_DIR = pathlib.Path(os.getenv("MEDIA_VIDEOS_DIR", "/mnt/videos")).resolve()
THUMBS_DIR = pathlib.Path(os.getenv("MEDIA_THUMBS_DIR", MEDIA_DIR / ".thumbnails")).resolve()
app = FastAPI(title="Media API", version="0.1.0")
# CORS for dashboard at exp-dash:8080 (and localhost for convenience)
app.add_middleware(
CORSMiddleware,
allow_origins=["http://exp-dash:8080", "http://localhost:8080"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"]
)
def list_video_files() -> List[pathlib.Path]:
exts = {".mp4", ".avi", ".mov", ".mkv"}
files: List[pathlib.Path] = []
for root, _, fnames in os.walk(MEDIA_DIR):
# skip thumbnails dir
if THUMBS_DIR.as_posix() in pathlib.Path(root).as_posix():
continue
for f in fnames:
p = pathlib.Path(root) / f
if p.suffix.lower() in exts:
files.append(p)
return sorted(files, key=lambda p: p.stat().st_mtime, reverse=True)
def file_id_from_path(p: pathlib.Path) -> str:
return urllib.parse.quote_plus(p.relative_to(MEDIA_DIR).as_posix())
def path_from_file_id(fid: str) -> pathlib.Path:
rel = urllib.parse.unquote_plus(fid)
p = (MEDIA_DIR / rel).resolve()
if not p.is_file() or MEDIA_DIR not in p.parents:
raise HTTPException(status_code=404, detail="Video not found")
return p
def ensure_thumbnail(p: pathlib.Path, width: int = 320, height: int = 180) -> pathlib.Path:
THUMBS_DIR.mkdir(parents=True, exist_ok=True)
thumb_name = file_id_from_path(p) + f"_{width}x{height}.jpg"
out = THUMBS_DIR / thumb_name
if out.exists():
return out
# generate from 1s offset or 0 if too short
cmd = [
"ffmpeg", "-y",
"-ss", "1",
"-i", str(p),
"-vframes", "1",
"-vf", f"scale='min({width},iw)':-2,scale={width}:{height}:force_original_aspect_ratio=decrease,pad={width}:{height}:(ow-iw)/2:(oh-ih)/2",
str(out)
]
try:
subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
except subprocess.CalledProcessError:
raise HTTPException(status_code=500, detail="Failed to generate thumbnail")
return out
@app.get("/health")
def health() -> dict:
return {"status": "ok"}
@app.get("/videos/")
def list_videos(camera_name: Optional[str] = None, start_date: Optional[str] = None, end_date: Optional[str] = None,
limit: int = 24, page: int = 1) -> dict:
files = list_video_files()
# basic filtering placeholder (camera_name can be inferred from path segments)
if camera_name:
files = [p for p in files if camera_name in p.as_posix()]
total = len(files)
start = max(0, (page - 1) * limit)
slice_ = files[start:start + limit]
items = []
for p in slice_:
stat = p.stat()
items.append({
"file_id": file_id_from_path(p),
"camera_name": p.parent.name,
"filename": p.name,
"file_size_bytes": stat.st_size,
"format": p.suffix.lstrip('.'),
"status": "completed",
"created_at": int(stat.st_mtime),
"is_streamable": True,
"needs_conversion": False,
})
total_pages = (total + limit - 1) // limit if limit else 1
return {
"videos": items,
"total_count": total,
"page": page,
"total_pages": total_pages,
}
@app.get("/videos/{file_id:path}/thumbnail")
def get_thumbnail(file_id: str, width: int = 320, height: int = 180):
p = path_from_file_id(file_id)
thumb = ensure_thumbnail(p, width, height)
return FileResponse(thumb, media_type="image/jpeg")
# Convenience endpoint: pass file_id via query instead of path (accepts raw or URL-encoded)
@app.get("/videos/thumbnail")
def get_thumbnail_q(file_id: str, width: int = 320, height: int = 180):
p = path_from_file_id(file_id)
thumb = ensure_thumbnail(p, width, height)
return FileResponse(thumb, media_type="image/jpeg")
def get_video_mime_type(path: pathlib.Path) -> str:
"""Get MIME type based on file extension"""
ext = path.suffix.lower()
mime_types = {
".mp4": "video/mp4",
".avi": "video/x-msvideo",
".mov": "video/quicktime",
".mkv": "video/x-matroska",
".webm": "video/webm",
}
return mime_types.get(ext, "video/mp4")
def open_file_range(path: pathlib.Path, start: int, end: Optional[int]):
file_size = path.stat().st_size
if end is None or end >= file_size:
end = file_size - 1
length = end - start + 1
with open(path, 'rb') as f:
f.seek(start)
chunk = f.read(length)
return chunk, file_size, start, end
@app.head("/videos/{file_id:path}/stream")
@app.get("/videos/{file_id:path}/stream")
def stream_file(request: Request, file_id: str):
p = path_from_file_id(file_id)
file_size = p.stat().st_size
content_type = get_video_mime_type(p)
range_header = request.headers.get("range")
# Base headers for all responses
base_headers = {
"Accept-Ranges": "bytes",
"Content-Type": content_type,
"Cache-Control": "public, max-age=3600",
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, HEAD, OPTIONS",
"Access-Control-Allow-Headers": "Range, Content-Type",
"Access-Control-Expose-Headers": "Content-Range, Accept-Ranges, Content-Length",
}
# For HEAD requests, just return headers without body
if request.method == "HEAD":
headers = {
**base_headers,
"Content-Length": str(file_size),
}
return Response(status_code=200, headers=headers)
if not range_header:
# No range request - return full file with proper headers
headers = {
**base_headers,
"Content-Length": str(file_size),
}
return FileResponse(
p,
media_type=content_type,
headers=headers,
status_code=200
)
# Parse range request: bytes=START-END
range_value = range_header.strip().lower().replace("bytes=", "")
start_str, _, end_str = range_value.partition("-")
try:
start = int(start_str) if start_str else 0
end = int(end_str) if end_str else None
except ValueError:
raise HTTPException(status_code=400, detail="Bad Range header")
# Validate range
if start < 0:
start = 0
if end is None or end >= file_size:
end = file_size - 1
if start > end:
raise HTTPException(status_code=416, detail="Range Not Satisfiable")
chunk, size, actual_start, actual_end = open_file_range(p, start, end)
headers = {
**base_headers,
"Content-Range": f"bytes {actual_start}-{actual_end}/{size}",
"Content-Length": str(len(chunk)),
}
return Response(content=chunk, status_code=206, headers=headers)
# Convenience endpoint: pass file_id via query instead of path (accepts raw or URL-encoded)
@app.head("/videos/stream")
@app.get("/videos/stream")
def stream_file_q(request: Request, file_id: str):
return stream_file(request, file_id)
# Handle OPTIONS requests for CORS preflight
@app.options("/videos/{file_id:path}/stream")
@app.options("/videos/stream")
@app.options("/videos/{file_id:path}/stream-transcoded")
@app.options("/videos/stream-transcoded")
def stream_options():
return Response(
status_code=200,
headers={
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, HEAD, OPTIONS",
"Access-Control-Allow-Headers": "Range, Content-Type",
"Access-Control-Max-Age": "86400",
}
)
def generate_transcoded_stream(file_path: pathlib.Path, start_time: float = 0.0):
"""
Transcode video to H.264 on-the-fly using FFmpeg.
Streams H.264/MP4 that browsers can actually play.
"""
# FFmpeg command to transcode to H.264 with web-optimized settings
cmd = [
"ffmpeg",
"-i", str(file_path),
"-c:v", "libx264", # H.264 codec
"-preset", "ultrafast", # Fast encoding for real-time
"-tune", "zerolatency", # Low latency
"-crf", "23", # Quality (18-28, lower = better)
"-c:a", "aac", # AAC audio if present
"-movflags", "+faststart", # Web-optimized (moov atom at beginning)
"-f", "mp4", # MP4 container
"-" # Output to stdout
]
# If seeking to specific time
if start_time > 0:
cmd.insert(-2, "-ss")
cmd.insert(-2, str(start_time))
try:
process = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
bufsize=8192
)
# Stream chunks
chunk_size = 8192
while True:
chunk = process.stdout.read(chunk_size)
if not chunk:
break
yield chunk
process.wait()
except Exception as e:
print(f"FFmpeg transcoding error: {e}")
raise
@app.head("/videos/{file_id:path}/stream-transcoded")
@app.get("/videos/{file_id:path}/stream-transcoded")
@app.head("/videos/stream-transcoded")
@app.get("/videos/stream-transcoded")
def stream_transcoded(request: Request, file_id: str, start_time: float = 0.0):
"""
Stream video transcoded to H.264 on-the-fly.
This endpoint converts MPEG-4 Part 2 videos to H.264 for browser compatibility.
"""
p = path_from_file_id(file_id)
content_type = "video/mp4"
# Base headers
headers = {
"Content-Type": content_type,
"Cache-Control": "no-cache, no-store, must-revalidate",
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, HEAD, OPTIONS",
"Access-Control-Allow-Headers": "Range, Content-Type",
"Access-Control-Expose-Headers": "Content-Range, Accept-Ranges, Content-Length",
}
# For HEAD requests, just return headers (we can't know size without transcoding)
if request.method == "HEAD":
return Response(status_code=200, headers=headers)
# Note: Range requests are complex with transcoding, so we'll transcode from start
# For better performance with range requests, we'd need to cache transcoded segments
return StreamingResponse(
generate_transcoded_stream(p, start_time),
media_type=content_type,
headers=headers
)