Files
usda-vision/media-api/main.py
salirezav 5070d9b2ca Enhance media API transcoding and video streaming capabilities
- Added support for limiting concurrent transcoding operations in the media API to prevent resource exhaustion.
- Implemented functions to retrieve video duration and bitrate using ffprobe for improved streaming performance.
- Enhanced the generate_transcoded_stream function to handle HTTP range requests, allowing for more efficient video playback.
- Updated VideoModal component to disable fluid and responsive modes, ensuring proper container boundaries during video playback.
- Improved logging throughout the transcoding process for better error tracking and debugging.
2025-11-04 11:55:27 -05:00

561 lines
21 KiB
Python

import os
import pathlib
import subprocess
import threading
import time
import urllib.parse
from typing import List, Optional, Tuple
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()
# Limit concurrent transcoding operations to prevent resource exhaustion
# Adjust based on your CPU cores and available memory
MAX_CONCURRENT_TRANSCODING = int(os.getenv("MAX_CONCURRENT_TRANSCODING", "2"))
transcoding_semaphore = threading.Semaphore(MAX_CONCURRENT_TRANSCODING)
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 get_video_info(file_path: pathlib.Path) -> Tuple[float, Optional[int]]:
"""Get video duration and bitrate using ffprobe"""
try:
# Get duration
cmd_duration = [
"ffprobe",
"-v", "error",
"-show_entries", "format=duration",
"-of", "default=noprint_wrappers=1:nokey=1",
str(file_path)
]
result_duration = subprocess.run(cmd_duration, capture_output=True, text=True, check=True)
duration = float(result_duration.stdout.strip())
# Get bitrate
cmd_bitrate = [
"ffprobe",
"-v", "error",
"-show_entries", "format=bit_rate",
"-of", "default=noprint_wrappers=1:nokey=1",
str(file_path)
]
result_bitrate = subprocess.run(cmd_bitrate, capture_output=True, text=True, check=True)
bitrate_str = result_bitrate.stdout.strip()
bitrate = int(bitrate_str) if bitrate_str and bitrate_str.isdigit() else None
return duration, bitrate
except (subprocess.CalledProcessError, ValueError):
# Fallback: estimate from file size (very rough estimate)
file_size_mb = file_path.stat().st_size / (1024 * 1024)
duration = max(10.0, file_size_mb * 20) # Rough estimate: 20 seconds per MB
return duration, None
def generate_transcoded_stream(file_path: pathlib.Path, start_time: float = 0.0, duration: Optional[float] = None):
"""
Transcode video to H.264 on-the-fly using FFmpeg.
Streams H.264/MP4 that browsers can actually play.
Uses semaphore to limit concurrent transcoding operations.
"""
if not file_path.exists():
raise HTTPException(status_code=404, detail="Video file not found")
if file_path.stat().st_size == 0:
raise HTTPException(status_code=500, detail="Video file is empty (0 bytes)")
# Acquire semaphore to limit concurrent transcoding
# This prevents resource exhaustion from too many simultaneous FFmpeg processes
semaphore_acquired = False
try:
if not transcoding_semaphore.acquire(blocking=False):
raise HTTPException(
status_code=503,
detail=f"Server busy: Maximum concurrent transcoding operations ({MAX_CONCURRENT_TRANSCODING}) reached. Please try again in a moment."
)
semaphore_acquired = True
# FFmpeg command to transcode to H.264 with web-optimized settings
# Use fragmented MP4 for HTTP streaming (doesn't require seekable output)
# frag_keyframe: fragment at keyframes
# dash: use DASH-compatible fragmentation
# omit_tfhd_offset: avoid seeking by omitting track fragment header offset
# Optimized for resource usage: ultrafast preset, limited threads
# Build command with proper order: input seeking first, then input, then filters/codecs
cmd = ["ffmpeg"]
# If seeking to specific time, use input seeking (before -i, more accurate)
if start_time > 0:
cmd.extend(["-ss", str(start_time)])
# Input file
cmd.extend(["-i", str(file_path)])
# Video codec settings
cmd.extend([
"-c:v", "libx264", # H.264 codec
"-preset", "ultrafast", # Fast encoding for real-time (lowest CPU usage)
"-tune", "zerolatency", # Low latency
"-crf", "23", # Quality (18-28, lower = better)
"-threads", "2", # Limit threads to reduce CPU usage (adjust based on CPU cores)
"-max_muxing_queue_size", "1024", # Prevent buffer overflow
])
# If duration is specified (for range requests), limit output duration
if duration is not None:
cmd.extend(["-t", str(duration)])
# Audio codec settings
cmd.extend([
"-c:a", "aac", # AAC audio if present
"-b:a", "128k", # Limit audio bitrate to save resources
])
# Output format settings
cmd.extend([
"-movflags", "frag_keyframe+dash+omit_tfhd_offset", # Fragmented MP4 optimized for HTTP streaming
"-f", "mp4", # MP4 container
"-" # Output to stdout
])
process = None
stderr_thread = None
try:
process = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
bufsize=0 # Unbuffered for better streaming
)
# Stream chunks
chunk_size = 8192
bytes_yielded = 0
stderr_data = []
# Read stderr in background (to avoid blocking)
def read_stderr():
while True:
chunk = process.stderr.read(1024)
if not chunk:
break
stderr_data.append(chunk)
stderr_thread = threading.Thread(target=read_stderr, daemon=True)
stderr_thread.start()
try:
while True:
chunk = process.stdout.read(chunk_size)
if not chunk:
break
bytes_yielded += len(chunk)
yield chunk
except GeneratorExit:
# Generator was closed/stopped - cleanup process
if process and process.poll() is None:
process.terminate()
# Wait briefly, then force kill if needed
time.sleep(0.5)
if process.poll() is None:
try:
process.kill()
except Exception:
pass
raise
except Exception as e:
# Error during streaming - cleanup and re-raise
if process and process.poll() is None:
process.terminate()
# Wait briefly, then force kill if needed
time.sleep(0.5)
if process.poll() is None:
try:
process.kill()
except Exception:
pass
raise
# Wait for process to finish and stderr thread to complete
process.wait()
if stderr_thread:
stderr_thread.join(timeout=1)
# Check for errors
if process.returncode != 0:
stderr = b''.join(stderr_data).decode('utf-8', errors='ignore')
# Extract actual error message (skip version banner)
error_lines = stderr.split('\n')
# Skip version/configuration lines and get actual error
error_msg = '\n'.join([line for line in error_lines if line and
not line.startswith('ffmpeg version') and
not line.startswith('built with') and
not line.startswith('configuration:') and
not line.startswith('libav') and
'Copyright' not in line])
# If no meaningful error found, use last few lines
if not error_msg.strip():
error_msg = '\n'.join(error_lines[-10:])
print(f"FFmpeg error (code {process.returncode}): Full stderr:\n{stderr}")
print(f"FFmpeg command was: {' '.join(cmd)}")
if bytes_yielded == 0:
# Show first 500 chars of actual error (not just version info)
error_detail = error_msg[:500] if error_msg else stderr[:500]
raise HTTPException(status_code=500, detail=f"FFmpeg transcoding failed: {error_detail}")
except HTTPException:
raise
except Exception as e:
# Ensure process is cleaned up on any error
if process and process.poll() is None:
try:
process.terminate()
# Wait briefly, then force kill if needed
time.sleep(0.5)
if process.poll() is None:
try:
process.kill()
except Exception:
pass
except Exception:
try:
process.kill()
except Exception:
pass
print(f"FFmpeg transcoding error: {e}")
raise HTTPException(status_code=500, detail=f"Transcoding error: {str(e)}")
finally:
# Always release semaphore when done (success or error)
# Only release if we actually acquired it
if semaphore_acquired:
try:
transcoding_semaphore.release()
except Exception as e:
print(f"Error releasing semaphore: {e}")
@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.
Supports seeking via HTTP Range requests or start_time parameter.
"""
p = path_from_file_id(file_id)
content_type = "video/mp4"
# Get video duration and bitrate for range request handling
video_duration, original_bitrate = get_video_info(p)
# 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",
"Accept-Ranges": "bytes",
}
# For HEAD requests, return headers (estimate size)
if request.method == "HEAD":
# Rough estimate: ~2-3 MB per minute of video
estimated_size = int(video_duration * 50000) # ~50KB per second estimate
headers["Content-Length"] = str(estimated_size)
return Response(status_code=200, headers=headers)
# Handle Range requests for seeking
range_header = request.headers.get("range")
if range_header:
# Parse range request: bytes=START-END
range_value = range_header.strip().lower().replace("bytes=", "")
start_str, _, end_str = range_value.partition("-")
try:
byte_start = int(start_str) if start_str else 0
byte_end = int(end_str) if end_str else None
except ValueError:
# Invalid range, ignore and stream from start
range_header = None
if range_header:
# For seeking, convert byte range to time-based seeking
# Estimate transcoded bitrate (H.264 is typically more efficient than original)
# Use original bitrate if available, otherwise estimate
if original_bitrate:
# H.264 transcoding typically uses 70-80% of original bitrate at same quality
transcoded_bitrate = int(original_bitrate * 0.75)
else:
# Default estimate: 2 Mbps
transcoded_bitrate = 2000000
estimated_total_bytes = int(video_duration * transcoded_bitrate / 8)
if estimated_total_bytes > 0 and byte_start < estimated_total_bytes:
# Calculate time position from byte offset
time_start_sec = (byte_start / estimated_total_bytes) * video_duration
time_start_sec = max(0.0, min(time_start_sec, video_duration - 0.5))
# For seeking, don't limit duration - stream to end
# The browser will handle buffering
duration_sec = None # None means stream to end
# Update headers for range response
# For seeking, we typically don't know the exact end, so estimate
actual_byte_end = min(byte_end or estimated_total_bytes - 1, estimated_total_bytes - 1)
headers["Content-Range"] = f"bytes {byte_start}-{actual_byte_end}/{estimated_total_bytes}"
headers["Content-Length"] = str(actual_byte_end - byte_start + 1)
# Stream from the calculated time position using FFmpeg's -ss flag
# Duration is None, so it will stream to the end
return StreamingResponse(
generate_transcoded_stream(p, time_start_sec, duration_sec),
media_type=content_type,
headers=headers,
status_code=206 # Partial Content
)
# No range request or invalid range - stream from start_time
return StreamingResponse(
generate_transcoded_stream(p, start_time),
media_type=content_type,
headers=headers
)