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.
This commit is contained in:
@@ -1,8 +1,10 @@
|
||||
import os
|
||||
import pathlib
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
import urllib.parse
|
||||
from typing import List, Optional
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Response, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
@@ -11,6 +13,11 @@ 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)
|
||||
@@ -238,10 +245,45 @@ def stream_options():
|
||||
)
|
||||
|
||||
|
||||
def generate_transcoded_stream(file_path: pathlib.Path, start_time: float = 0.0):
|
||||
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")
|
||||
@@ -249,56 +291,176 @@ def generate_transcoded_stream(file_path: pathlib.Path, start_time: float = 0.0)
|
||||
if file_path.stat().st_size == 0:
|
||||
raise HTTPException(status_code=500, detail="Video file is empty (0 bytes)")
|
||||
|
||||
# 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))
|
||||
|
||||
# Acquire semaphore to limit concurrent transcoding
|
||||
# This prevents resource exhaustion from too many simultaneous FFmpeg processes
|
||||
semaphore_acquired = False
|
||||
try:
|
||||
process = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
bufsize=8192
|
||||
)
|
||||
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"]
|
||||
|
||||
# Stream chunks
|
||||
chunk_size = 8192
|
||||
bytes_yielded = 0
|
||||
while True:
|
||||
chunk = process.stdout.read(chunk_size)
|
||||
if not chunk:
|
||||
break
|
||||
bytes_yielded += len(chunk)
|
||||
yield chunk
|
||||
# If seeking to specific time, use input seeking (before -i, more accurate)
|
||||
if start_time > 0:
|
||||
cmd.extend(["-ss", str(start_time)])
|
||||
|
||||
# Check for errors
|
||||
process.wait()
|
||||
if process.returncode != 0:
|
||||
stderr = process.stderr.read().decode('utf-8', errors='ignore')
|
||||
print(f"FFmpeg error (code {process.returncode}): {stderr}")
|
||||
if bytes_yielded == 0:
|
||||
raise HTTPException(status_code=500, detail=f"FFmpeg transcoding failed: {stderr[:200]}")
|
||||
# Input file
|
||||
cmd.extend(["-i", str(file_path)])
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
print(f"FFmpeg transcoding error: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Transcoding error: {str(e)}")
|
||||
# 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")
|
||||
@@ -309,10 +471,14 @@ 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,
|
||||
@@ -321,14 +487,69 @@ def stream_transcoded(request: Request, file_id: str, start_time: float = 0.0):
|
||||
"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, just return headers (we can't know size without transcoding)
|
||||
# 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)
|
||||
|
||||
# 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
|
||||
# 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,
|
||||
|
||||
Reference in New Issue
Block a user