Compare commits
4 Commits
b957fd7631
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
3a4d9fe45d
|
|||
|
eeec3582c9
|
|||
|
49a54616b9
|
|||
|
3dc93f147b
|
2
.gitignore
vendored
2
.gitignore
vendored
@ -1,2 +1,4 @@
|
|||||||
*.venv
|
*.venv
|
||||||
*.pyc
|
*.pyc
|
||||||
|
*segment*
|
||||||
|
*playlist*
|
||||||
Submodule beacon_track updated: f3775924eb...ce0d728eee
@ -5,7 +5,7 @@ This application reads JPEG frames from shared memory and streams them
|
|||||||
to web browsers via Server-Sent Events (SSE) or WebSocket (H.264 stream).
|
to web browsers via Server-Sent Events (SSE) or WebSocket (H.264 stream).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Request
|
from fastapi import FastAPI, Request
|
||||||
from fastapi.responses import HTMLResponse, StreamingResponse, Response
|
from fastapi.responses import HTMLResponse, StreamingResponse, Response
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
@ -15,7 +15,6 @@ import time
|
|||||||
import base64
|
import base64
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
from typing import Set
|
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
# Global state
|
# Global state
|
||||||
@ -26,11 +25,10 @@ last_frame_header = None
|
|||||||
frame_lock = asyncio.Lock()
|
frame_lock = asyncio.Lock()
|
||||||
|
|
||||||
# Video streaming state
|
# Video streaming state
|
||||||
active_websocket_clients: Set[WebSocket] = set()
|
|
||||||
streaming_task = None
|
streaming_task = None
|
||||||
streaming_active = False
|
streaming_active = False
|
||||||
TCP_HOST = '127.0.0.1'
|
VIDEO_FIFO_PATH = '/tmp/beacon_video_stream' # Named pipe for H.264 stream
|
||||||
TCP_PORT = 8888
|
video_fifo_file = None # Keep FIFO open to prevent C++ blocking
|
||||||
|
|
||||||
|
|
||||||
async def init_reader():
|
async def init_reader():
|
||||||
@ -46,23 +44,80 @@ async def init_reader():
|
|||||||
reader = None
|
reader = None
|
||||||
|
|
||||||
|
|
||||||
|
async def init_video_fifo():
|
||||||
|
"""Initialize video FIFO - open it to unblock C++ writer"""
|
||||||
|
global video_fifo_file
|
||||||
|
|
||||||
|
# Wait for C++ to create FIFO
|
||||||
|
for i in range(30):
|
||||||
|
if os.path.exists(VIDEO_FIFO_PATH):
|
||||||
|
break
|
||||||
|
print(f"Waiting for FIFO to be created by C++ ({i+1}/30)...")
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
|
||||||
|
if not os.path.exists(VIDEO_FIFO_PATH):
|
||||||
|
print(f"WARNING: Video FIFO not found at {VIDEO_FIFO_PATH}")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
print(f"Opening FIFO {VIDEO_FIFO_PATH} for reading (non-blocking mode)...")
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
|
||||||
|
# Open file in NON-BLOCKING mode to prevent deadlock
|
||||||
|
# GStreamer filesink opens FIFO lazily (only when first frame arrives)
|
||||||
|
# Using O_NONBLOCK prevents Python from blocking while waiting for C++ writer
|
||||||
|
def open_fifo():
|
||||||
|
fd = os.open(VIDEO_FIFO_PATH, os.O_RDONLY | os.O_NONBLOCK)
|
||||||
|
return os.fdopen(fd, 'rb', buffering=0)
|
||||||
|
|
||||||
|
video_fifo_file = await loop.run_in_executor(None, open_fifo)
|
||||||
|
print(f"FIFO opened successfully in non-blocking mode")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Failed to open video FIFO: {e}")
|
||||||
|
|
||||||
|
|
||||||
async def cleanup_reader():
|
async def cleanup_reader():
|
||||||
"""Clean up shared memory reader"""
|
"""Clean up shared memory reader"""
|
||||||
global reader
|
global reader, video_fifo_file
|
||||||
async with reader_lock:
|
async with reader_lock:
|
||||||
if reader:
|
if reader:
|
||||||
reader.close()
|
reader.close()
|
||||||
reader = None
|
reader = None
|
||||||
|
|
||||||
|
# Close video FIFO
|
||||||
|
if video_fifo_file:
|
||||||
|
try:
|
||||||
|
video_fifo_file.close()
|
||||||
|
print("Video FIFO closed")
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
video_fifo_file = None
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
"""Lifespan context manager for startup and shutdown events"""
|
"""Lifespan context manager for startup and shutdown events"""
|
||||||
|
global streaming_task, streaming_active
|
||||||
|
|
||||||
# Startup
|
# Startup
|
||||||
await init_reader()
|
await init_reader()
|
||||||
|
await init_video_fifo()
|
||||||
|
|
||||||
|
# Start HLS streaming task
|
||||||
|
streaming_task = asyncio.create_task(stream_video_from_fifo())
|
||||||
|
print("Started HLS video streaming task")
|
||||||
|
|
||||||
print("FastAPI application started")
|
print("FastAPI application started")
|
||||||
yield
|
yield
|
||||||
|
|
||||||
# Shutdown
|
# Shutdown
|
||||||
|
streaming_active = False
|
||||||
|
if streaming_task:
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(streaming_task, timeout=5.0)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
print("HLS streaming task did not stop gracefully")
|
||||||
|
|
||||||
await cleanup_reader()
|
await cleanup_reader()
|
||||||
print("FastAPI application shutdown")
|
print("FastAPI application shutdown")
|
||||||
|
|
||||||
@ -207,123 +262,254 @@ async def latest_frame():
|
|||||||
return Response(content="No frame available", status_code=404)
|
return Response(content="No frame available", status_code=404)
|
||||||
|
|
||||||
|
|
||||||
async def stream_video_from_tcp():
|
async def stream_video_from_fifo():
|
||||||
"""
|
"""
|
||||||
Read MPEG-TS video stream from TCP socket and broadcast via WebSocket.
|
Read raw H.264 from FIFO and convert to HLS format via ffmpeg.
|
||||||
This runs as a background task.
|
This runs as a background task and generates HLS playlist + segments.
|
||||||
"""
|
"""
|
||||||
global streaming_active
|
global streaming_active, video_fifo_file
|
||||||
|
import subprocess
|
||||||
|
|
||||||
print(f"Starting video stream reader from TCP: {TCP_HOST}:{TCP_PORT}")
|
print(f"Starting HLS video stream generator from FIFO: {VIDEO_FIFO_PATH}")
|
||||||
|
|
||||||
# Wait a bit for C++ application to start TCP server
|
ffmpeg_process = None
|
||||||
await asyncio.sleep(2)
|
hls_dir = "hls_output"
|
||||||
|
|
||||||
|
# Create HLS output directory if it doesn't exist
|
||||||
|
if not os.path.exists(hls_dir):
|
||||||
|
os.makedirs(hls_dir)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Connect to TCP server
|
# Use the globally opened FIFO file
|
||||||
print(f"Connecting to TCP server {TCP_HOST}:{TCP_PORT}...")
|
if video_fifo_file is None:
|
||||||
|
print("ERROR: FIFO not opened during startup")
|
||||||
|
return
|
||||||
|
|
||||||
reader, writer = await asyncio.open_connection(TCP_HOST, TCP_PORT)
|
fifo = video_fifo_file
|
||||||
|
|
||||||
print("Connected to TCP server, starting stream...")
|
print("Connected to FIFO, starting ffmpeg H.264->HLS converter...")
|
||||||
|
|
||||||
|
# Start ffmpeg to convert raw H.264 to HLS format
|
||||||
|
# -probesize 32 -analyzeduration 0: minimal probing for low latency startup
|
||||||
|
# -f h264: input format is raw H.264 byte-stream (Annex B from GStreamer)
|
||||||
|
# -r 30: input framerate (from config.ini StreamFps)
|
||||||
|
# -i pipe:0: read from stdin
|
||||||
|
# -c:v copy: copy video codec without re-encoding (use hardware-encoded H.264)
|
||||||
|
# -f hls: output HLS format
|
||||||
|
# -hls_time 1: 1 second per segment (low latency)
|
||||||
|
# -hls_list_size 10: keep 10 segments in playlist (more buffer for clients)
|
||||||
|
# -hls_delete_threshold 3: delete segments only after 3 new ones created
|
||||||
|
# -hls_flags delete_segments+append_list+omit_endlist: live streaming flags
|
||||||
|
# -hls_segment_type mpegts: use MPEG-TS segments
|
||||||
|
# -hls_segment_filename: use %d for unlimited numbering
|
||||||
|
# -start_number 0: start segment numbering from 0
|
||||||
|
ffmpeg_process = subprocess.Popen(
|
||||||
|
['ffmpeg',
|
||||||
|
'-probesize', '32',
|
||||||
|
'-analyzeduration', '0',
|
||||||
|
'-f', 'h264',
|
||||||
|
'-r', '30', # Must match GStreamer StreamFps from config.ini
|
||||||
|
'-i', 'pipe:0',
|
||||||
|
'-c:v', 'copy',
|
||||||
|
'-f', 'hls',
|
||||||
|
'-hls_time', '1',
|
||||||
|
'-hls_list_size', '10',
|
||||||
|
'-hls_delete_threshold', '3',
|
||||||
|
'-hls_flags', 'delete_segments+append_list+omit_endlist',
|
||||||
|
'-hls_segment_type', 'mpegts',
|
||||||
|
'-hls_segment_filename', f'{hls_dir}/segment_%d.ts',
|
||||||
|
'-start_number', '0',
|
||||||
|
f'{hls_dir}/playlist.m3u8'],
|
||||||
|
stdin=subprocess.PIPE,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE
|
||||||
|
)
|
||||||
|
|
||||||
streaming_active = True
|
streaming_active = True
|
||||||
chunk_size = 32768 # 32KB chunks for MPEG-TS
|
chunk_size = 65536 # 64KB chunks
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
|
||||||
|
bytes_written = 0
|
||||||
|
|
||||||
|
print(f"Reading raw H.264 from FIFO and generating HLS segments...")
|
||||||
|
|
||||||
|
async def read_from_fifo():
|
||||||
|
"""Read from FIFO and pipe to ffmpeg stdin"""
|
||||||
|
nonlocal bytes_written
|
||||||
|
writer_connected = False
|
||||||
|
|
||||||
while streaming_active and active_websocket_clients:
|
|
||||||
try:
|
try:
|
||||||
# Read chunk from TCP
|
while streaming_active:
|
||||||
data = await asyncio.wait_for(reader.read(chunk_size), timeout=5.0)
|
def read_chunk():
|
||||||
|
try:
|
||||||
|
return fifo.read(chunk_size)
|
||||||
|
except BlockingIOError:
|
||||||
|
# No data available yet (writer not connected or no data)
|
||||||
|
return None
|
||||||
|
|
||||||
if not data:
|
data = await loop.run_in_executor(None, read_chunk)
|
||||||
print("No data from TCP, stream may have ended")
|
|
||||||
break
|
|
||||||
|
|
||||||
# Broadcast binary data to all connected WebSocket clients
|
if data is None:
|
||||||
disconnected_clients = set()
|
# BlockingIOError: no data available, wait a bit
|
||||||
for client in active_websocket_clients.copy():
|
if not writer_connected:
|
||||||
|
print("Waiting for C++ to start writing to FIFO...")
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not data:
|
||||||
|
# Empty data: EOF (writer closed the FIFO)
|
||||||
|
if writer_connected:
|
||||||
|
print("FIFO EOF reached - C++ stopped writing")
|
||||||
|
else:
|
||||||
|
print("FIFO closed before C++ started writing")
|
||||||
|
break
|
||||||
|
|
||||||
|
# Got data! Mark writer as connected
|
||||||
|
if not writer_connected:
|
||||||
|
writer_connected = True
|
||||||
|
print("C++ started writing to FIFO successfully")
|
||||||
|
|
||||||
|
if ffmpeg_process and ffmpeg_process.stdin:
|
||||||
|
try:
|
||||||
|
ffmpeg_process.stdin.write(data)
|
||||||
|
ffmpeg_process.stdin.flush()
|
||||||
|
bytes_written += len(data)
|
||||||
|
|
||||||
|
# Log progress every 1MB
|
||||||
|
if bytes_written % (1024 * 1024) < chunk_size:
|
||||||
|
print(f"Written {bytes_written / 1024 / 1024:.2f} MB to ffmpeg for HLS encoding")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error writing to ffmpeg stdin: {e}")
|
||||||
|
break
|
||||||
|
finally:
|
||||||
|
if ffmpeg_process and ffmpeg_process.stdin:
|
||||||
try:
|
try:
|
||||||
await client.send_bytes(data)
|
ffmpeg_process.stdin.close()
|
||||||
except Exception as e:
|
print("Closed ffmpeg stdin")
|
||||||
print(f"Error sending to client: {e}")
|
except:
|
||||||
disconnected_clients.add(client)
|
pass
|
||||||
|
|
||||||
# Remove disconnected clients
|
async def read_ffmpeg_stderr():
|
||||||
active_websocket_clients.difference_update(disconnected_clients)
|
"""Read and log ffmpeg stderr for debugging"""
|
||||||
|
line_count = 0
|
||||||
|
try:
|
||||||
|
while streaming_active:
|
||||||
|
def read_err():
|
||||||
|
line = ffmpeg_process.stderr.readline()
|
||||||
|
return line.decode('utf-8', errors='ignore') if line else None
|
||||||
|
|
||||||
except asyncio.TimeoutError:
|
line = await loop.run_in_executor(None, read_err)
|
||||||
# No data in 5 seconds, continue waiting
|
if not line:
|
||||||
continue
|
break
|
||||||
|
|
||||||
|
line_count += 1
|
||||||
|
|
||||||
|
# Log first 30 lines to see HLS setup, then only errors/warnings
|
||||||
|
if line_count <= 30:
|
||||||
|
# Skip empty lines and progress updates
|
||||||
|
if line.strip() and not line.startswith('frame='):
|
||||||
|
print(f"FFmpeg[{line_count}]: {line.strip()}")
|
||||||
|
elif 'error' in line.lower() or 'warning' in line.lower():
|
||||||
|
print(f"FFmpeg: {line.strip()}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error reading from TCP: {e}")
|
print(f"Error reading ffmpeg stderr: {e}")
|
||||||
break
|
|
||||||
|
|
||||||
writer.close()
|
# Run both tasks concurrently
|
||||||
await writer.wait_closed()
|
await asyncio.gather(
|
||||||
|
read_from_fifo(),
|
||||||
|
read_ffmpeg_stderr(),
|
||||||
|
return_exceptions=True
|
||||||
|
)
|
||||||
|
|
||||||
except ConnectionRefusedError:
|
except FileNotFoundError:
|
||||||
print(f"ERROR: Could not connect to TCP server {TCP_HOST}:{TCP_PORT}")
|
print(f"ERROR: Could not open FIFO {VIDEO_FIFO_PATH}")
|
||||||
print("Make sure C++ application is running first")
|
print("Make sure C++ application is running first")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"ERROR in video streaming: {e}")
|
print(f"ERROR in video streaming: {e}")
|
||||||
import traceback
|
import traceback
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
finally:
|
finally:
|
||||||
|
# Cleanup ffmpeg process
|
||||||
|
if ffmpeg_process:
|
||||||
|
try:
|
||||||
|
ffmpeg_process.terminate()
|
||||||
|
ffmpeg_process.wait(timeout=2)
|
||||||
|
except:
|
||||||
|
try:
|
||||||
|
ffmpeg_process.kill()
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
streaming_active = False
|
streaming_active = False
|
||||||
print("Video streaming stopped")
|
print("Video streaming stopped")
|
||||||
|
|
||||||
|
|
||||||
@app.websocket("/ws/video")
|
@app.get("/hls/playlist.m3u8")
|
||||||
async def websocket_video_endpoint(websocket: WebSocket):
|
async def get_hls_playlist():
|
||||||
"""WebSocket endpoint for video streaming"""
|
"""Serve HLS playlist manifest"""
|
||||||
global streaming_task, streaming_active
|
playlist_path = "hls_output/playlist.m3u8"
|
||||||
|
|
||||||
await websocket.accept()
|
if not os.path.exists(playlist_path):
|
||||||
print(f"Client connected to video stream: {id(websocket)}")
|
return Response(content="HLS playlist not ready yet", status_code=404)
|
||||||
|
|
||||||
# Add client to active set
|
|
||||||
active_websocket_clients.add(websocket)
|
|
||||||
|
|
||||||
# Start streaming task if not already running
|
|
||||||
if not streaming_active:
|
|
||||||
streaming_task = asyncio.create_task(stream_video_from_tcp())
|
|
||||||
print("Started video streaming task")
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Keep connection alive and handle client messages
|
with open(playlist_path, 'r') as f:
|
||||||
while True:
|
content = f.read()
|
||||||
# Wait for client messages (like ping/pong or control messages)
|
|
||||||
try:
|
|
||||||
data = await asyncio.wait_for(websocket.receive_text(), timeout=1.0)
|
|
||||||
# Handle client messages if needed
|
|
||||||
if data == "ping":
|
|
||||||
await websocket.send_text("pong")
|
|
||||||
except asyncio.TimeoutError:
|
|
||||||
# No message received, continue
|
|
||||||
continue
|
|
||||||
|
|
||||||
except WebSocketDisconnect:
|
return Response(
|
||||||
print(f"Client disconnected from video stream: {id(websocket)}")
|
content=content,
|
||||||
|
media_type="application/vnd.apple.mpegurl",
|
||||||
|
headers={
|
||||||
|
"Cache-Control": "no-cache, no-store, must-revalidate",
|
||||||
|
"Pragma": "no-cache",
|
||||||
|
"Expires": "0",
|
||||||
|
"Access-Control-Allow-Origin": "*"
|
||||||
|
}
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"WebSocket error: {e}")
|
print(f"Error serving HLS playlist: {e}")
|
||||||
finally:
|
return Response(content=f"Error: {e}", status_code=500)
|
||||||
# Remove client from active set
|
|
||||||
active_websocket_clients.discard(websocket)
|
|
||||||
|
|
||||||
# Stop streaming if no more clients
|
|
||||||
if not active_websocket_clients:
|
@app.get("/hls/{segment_name}")
|
||||||
streaming_active = False
|
async def get_hls_segment(segment_name: str):
|
||||||
print("No more clients, stopping stream")
|
"""Serve HLS video segments"""
|
||||||
|
# Security: only allow .ts files
|
||||||
|
if not segment_name.endswith('.ts'):
|
||||||
|
return Response(content="Invalid segment name", status_code=400)
|
||||||
|
|
||||||
|
segment_path = f"hls_output/{segment_name}"
|
||||||
|
|
||||||
|
if not os.path.exists(segment_path):
|
||||||
|
return Response(content="Segment not found", status_code=404)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(segment_path, 'rb') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
content=content,
|
||||||
|
media_type="video/MP2T",
|
||||||
|
headers={
|
||||||
|
"Cache-Control": "public, max-age=3600",
|
||||||
|
"Access-Control-Allow-Origin": "*"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error serving HLS segment {segment_name}: {e}")
|
||||||
|
return Response(content=f"Error: {e}", status_code=500)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
async def health():
|
async def health():
|
||||||
"""Health check endpoint"""
|
"""Health check endpoint"""
|
||||||
|
hls_playlist_exists = os.path.exists("hls_output/playlist.m3u8")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"status": "healthy",
|
"status": "healthy",
|
||||||
"active_websocket_clients": len(active_websocket_clients),
|
|
||||||
"streaming_active": streaming_active,
|
"streaming_active": streaming_active,
|
||||||
"tcp_endpoint": f"{TCP_HOST}:{TCP_PORT}"
|
"hls_playlist_ready": hls_playlist_exists,
|
||||||
|
"video_fifo": VIDEO_FIFO_PATH
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -332,7 +518,7 @@ if __name__ == '__main__':
|
|||||||
|
|
||||||
print("Starting FastAPI server on http://0.0.0.0:5000")
|
print("Starting FastAPI server on http://0.0.0.0:5000")
|
||||||
print(f"Video stream will be available at ws://0.0.0.0:5000/ws/video")
|
print(f"Video stream will be available at ws://0.0.0.0:5000/ws/video")
|
||||||
print(f"Expecting C++ TCP stream at {TCP_HOST}:{TCP_PORT}")
|
print(f"Expecting C++ H.264 stream via FIFO at {VIDEO_FIFO_PATH}")
|
||||||
|
|
||||||
uvicorn.run(
|
uvicorn.run(
|
||||||
app,
|
app,
|
||||||
|
|||||||
14
web_viewer/hls_output/playlist.m3u8
Normal file
14
web_viewer/hls_output/playlist.m3u8
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
#EXTM3U
|
||||||
|
#EXT-X-VERSION:3
|
||||||
|
#EXT-X-TARGETDURATION:1
|
||||||
|
#EXT-X-MEDIA-SEQUENCE:1097
|
||||||
|
#EXTINF:1.000000,
|
||||||
|
segment_1097.ts
|
||||||
|
#EXTINF:1.000000,
|
||||||
|
segment_1098.ts
|
||||||
|
#EXTINF:1.000000,
|
||||||
|
segment_1099.ts
|
||||||
|
#EXTINF:1.000000,
|
||||||
|
segment_1100.ts
|
||||||
|
#EXTINF:0.400000,
|
||||||
|
segment_1101.ts
|
||||||
BIN
web_viewer/hls_output/segment_715.ts
Normal file
BIN
web_viewer/hls_output/segment_715.ts
Normal file
Binary file not shown.
@ -3,4 +3,4 @@ uvicorn[standard]>=0.24.0
|
|||||||
python-multipart>=0.0.6
|
python-multipart>=0.0.6
|
||||||
posix_ipc>=1.1.0
|
posix_ipc>=1.1.0
|
||||||
websockets>=12.0
|
websockets>=12.0
|
||||||
jinja2
|
Jinja2==3.1.6
|
||||||
1
web_viewer/static/js/jsmpeg.min.js
vendored
Normal file
1
web_viewer/static/js/jsmpeg.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
@ -3,9 +3,9 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Beacon Tracker - Live Stream (FastAPI)</title>
|
<title>Beacon Tracker - Live Stream (HLS)</title>
|
||||||
<!-- JSMpeg for H.264/MPEG-TS decoding -->
|
<!-- HLS.js for H.264 HLS decoding -->
|
||||||
<script src="https://cdn.jsdelivr.net/npm/jsmpeg@0.1.0/jsmpeg.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/hls.js@latest"></script>
|
||||||
<style>
|
<style>
|
||||||
* {
|
* {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@ -92,12 +92,20 @@
|
|||||||
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.3);
|
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
#videoFrame, #videoCanvas {
|
#videoFrame {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: auto;
|
height: auto;
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#videoPlayer {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
display: block;
|
||||||
|
background: #000;
|
||||||
|
min-height: 480px;
|
||||||
|
}
|
||||||
|
|
||||||
.hidden {
|
.hidden {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
@ -276,24 +284,14 @@
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="main-content">
|
<div class="main-content">
|
||||||
<div class="stream-selector">
|
|
||||||
<span style="font-weight: 600;">Выбрать метод передачи:</span>
|
|
||||||
<button id="btnWebSocket" onclick="switchToWebSocket()" class="active">
|
|
||||||
WebSocket (H.264) <span class="perf-badge websocket">Быстрее</span>
|
|
||||||
</button>
|
|
||||||
<button id="btnSSE" onclick="switchToSSE()">
|
|
||||||
SSE (JPEG) <span class="perf-badge sse">Совместимость</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="video-container">
|
<div class="video-container">
|
||||||
<!-- Canvas for WebSocket/JSMpeg streaming -->
|
<!-- HTML5 video element for HLS playback -->
|
||||||
<canvas id="videoCanvas"></canvas>
|
<video id="videoPlayer" controls autoplay muted playsinline></video>
|
||||||
<!-- Image for SSE/JPEG streaming -->
|
<!-- Image for SSE/JPEG streaming (fallback) -->
|
||||||
<img id="videoFrame" class="hidden" alt="Video stream">
|
<img id="videoFrame" class="hidden" alt="Video stream">
|
||||||
<div class="loading-overlay" id="loadingOverlay">
|
<div class="loading-overlay" id="loadingOverlay">
|
||||||
<div class="spinner"></div>
|
<div class="spinner"></div>
|
||||||
<p>Ожидание данных...</p>
|
<p>Ожидание HLS потока...</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -326,21 +324,21 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// Stream mode: 'websocket' or 'sse'
|
// Stream mode: 'hls' or 'sse'
|
||||||
let streamMode = 'websocket';
|
let streamMode = 'hls';
|
||||||
|
|
||||||
// SSE variables
|
// SSE variables (fallback)
|
||||||
let eventSource = null;
|
let eventSource = null;
|
||||||
let sseFrameCount = 0;
|
let sseFrameCount = 0;
|
||||||
let sseLastFrameTime = Date.now();
|
let sseLastFrameTime = Date.now();
|
||||||
let sseTotalBytes = 0;
|
let sseTotalBytes = 0;
|
||||||
let sseLastBitrateCalc = Date.now();
|
let sseLastBitrateCalc = Date.now();
|
||||||
|
|
||||||
// WebSocket variables
|
// HLS variables
|
||||||
let websocket = null;
|
let hls = null;
|
||||||
let player = null;
|
let video = null;
|
||||||
let wsFrameCount = 0;
|
let hlsFrameCount = 0;
|
||||||
let wsLastFrameTime = Date.now();
|
let hlsLastFrameTime = Date.now();
|
||||||
let reconnectAttempts = 0;
|
let reconnectAttempts = 0;
|
||||||
const MAX_RECONNECT_ATTEMPTS = 5;
|
const MAX_RECONNECT_ATTEMPTS = 5;
|
||||||
|
|
||||||
@ -357,124 +355,107 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function switchToWebSocket() {
|
function connectHLS() {
|
||||||
if (streamMode === 'websocket') return;
|
document.getElementById('streamMethod').textContent = 'HLS (H.264)';
|
||||||
|
document.getElementById('videoPlayer').classList.remove('hidden');
|
||||||
streamMode = 'websocket';
|
|
||||||
document.getElementById('btnWebSocket').classList.add('active');
|
|
||||||
document.getElementById('btnSSE').classList.remove('active');
|
|
||||||
|
|
||||||
disconnectSSE();
|
|
||||||
connectWebSocket();
|
|
||||||
}
|
|
||||||
|
|
||||||
function switchToSSE() {
|
|
||||||
if (streamMode === 'sse') return;
|
|
||||||
|
|
||||||
streamMode = 'sse';
|
|
||||||
document.getElementById('btnSSE').classList.add('active');
|
|
||||||
document.getElementById('btnWebSocket').classList.remove('active');
|
|
||||||
|
|
||||||
disconnectWebSocket();
|
|
||||||
connectSSE();
|
|
||||||
}
|
|
||||||
|
|
||||||
function connectWebSocket() {
|
|
||||||
document.getElementById('streamMethod').textContent = 'WebSocket (H.264)';
|
|
||||||
document.getElementById('videoCanvas').classList.remove('hidden');
|
|
||||||
document.getElementById('videoFrame').classList.add('hidden');
|
document.getElementById('videoFrame').classList.add('hidden');
|
||||||
|
|
||||||
console.log('Connecting to WebSocket video stream...');
|
console.log('Connecting to HLS video stream...');
|
||||||
|
|
||||||
// Build WebSocket URL
|
video = document.getElementById('videoPlayer');
|
||||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
const hlsUrl = `${window.location.protocol}//${window.location.host}/hls/playlist.m3u8`;
|
||||||
const wsUrl = `${protocol}//${window.location.host}/ws/video`;
|
|
||||||
|
|
||||||
// Connect to native WebSocket
|
if (Hls.isSupported()) {
|
||||||
websocket = new WebSocket(wsUrl);
|
hls = new Hls({
|
||||||
websocket.binaryType = 'arraybuffer';
|
// Low latency configuration with increased buffer
|
||||||
|
lowLatencyMode: true,
|
||||||
|
backBufferLength: 90,
|
||||||
|
maxBufferLength: 10, // Increased from 3 to 10 seconds
|
||||||
|
maxBufferSize: 3 * 1024 * 1024, // Increased from 1MB to 3MB
|
||||||
|
liveSyncDurationCount: 3, // Keep 3 segments in sync
|
||||||
|
liveMaxLatencyDurationCount: 5, // Max 5 segments latency before catchup
|
||||||
|
maxMaxBufferLength: 30, // Maximum buffer length
|
||||||
|
enableWorker: true,
|
||||||
|
debug: false
|
||||||
|
});
|
||||||
|
|
||||||
websocket.onopen = () => {
|
hls.loadSource(hlsUrl);
|
||||||
console.log('WebSocket connected');
|
hls.attachMedia(video);
|
||||||
updateConnectionStatus(true);
|
|
||||||
hideError();
|
|
||||||
reconnectAttempts = 0;
|
|
||||||
|
|
||||||
// Initialize JSMpeg player
|
hls.on(Hls.Events.MANIFEST_PARSED, function() {
|
||||||
const canvas = document.getElementById('videoCanvas');
|
console.log('HLS manifest parsed, starting playback...');
|
||||||
player = new JSMpeg.Player(null, {
|
video.play().catch(e => {
|
||||||
canvas: canvas,
|
console.warn('Autoplay prevented, waiting for user interaction:', e);
|
||||||
disableGl: false,
|
});
|
||||||
disableWebAssembly: false,
|
updateConnectionStatus(true);
|
||||||
preserveDrawingBuffer: false,
|
hideError();
|
||||||
progressive: true,
|
});
|
||||||
throttled: true,
|
|
||||||
chunkSize: 1024 * 32,
|
hls.on(Hls.Events.ERROR, function(event, data) {
|
||||||
onVideoDecode: (decoder, time) => {
|
console.error('HLS error:', data.type, data.details);
|
||||||
// Update FPS counter
|
if (data.fatal) {
|
||||||
const now = Date.now();
|
switch(data.type) {
|
||||||
const timeDiff = (now - wsLastFrameTime) / 1000;
|
case Hls.ErrorTypes.NETWORK_ERROR:
|
||||||
if (timeDiff > 0.1) { // Update every 100ms
|
console.log('Network error, attempting to recover...');
|
||||||
fps = Math.round(1 / timeDiff);
|
hls.startLoad();
|
||||||
document.getElementById('fpsCounter').textContent = fps;
|
break;
|
||||||
wsFrameCount++;
|
case Hls.ErrorTypes.MEDIA_ERROR:
|
||||||
document.getElementById('frameCounter').textContent = wsFrameCount;
|
console.log('Media error, attempting to recover...');
|
||||||
|
hls.recoverMediaError();
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
console.error('Fatal error, cannot recover');
|
||||||
|
updateConnectionStatus(false);
|
||||||
|
showError('Ошибка HLS потока: ' + data.details);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
wsLastFrameTime = now;
|
|
||||||
|
|
||||||
// Update resolution
|
|
||||||
if (decoder.width && decoder.height) {
|
|
||||||
document.getElementById('resolution').textContent =
|
|
||||||
`${decoder.width}×${decoder.height}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hide loading overlay on first frame
|
|
||||||
document.getElementById('loadingOverlay').classList.add('hidden');
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
|
||||||
|
|
||||||
websocket.onmessage = (event) => {
|
// Hide loading overlay when playback starts
|
||||||
if (player && event.data instanceof ArrayBuffer) {
|
video.addEventListener('playing', () => {
|
||||||
const uint8Array = new Uint8Array(event.data);
|
console.log('Video playback started');
|
||||||
|
document.getElementById('loadingOverlay').classList.add('hidden');
|
||||||
|
updateConnectionStatus(true);
|
||||||
|
});
|
||||||
|
|
||||||
if (!player.source) {
|
// Track video stats
|
||||||
// Create source buffer if needed
|
video.addEventListener('timeupdate', () => {
|
||||||
player.source = {
|
const now = Date.now();
|
||||||
write: function(data) {
|
const timeDiff = (now - hlsLastFrameTime) / 1000;
|
||||||
if (player.demuxer && player.demuxer.write) {
|
if (timeDiff > 0.5) { // Update every 500ms
|
||||||
player.demuxer.write(data);
|
// Estimate FPS from video framerate
|
||||||
}
|
if (video.videoWidth && video.videoHeight) {
|
||||||
}
|
document.getElementById('resolution').textContent =
|
||||||
};
|
`${video.videoWidth}×${video.videoHeight}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate approximate FPS
|
||||||
|
fps = hls.media ? Math.round(1 / timeDiff * 10) : 0;
|
||||||
|
document.getElementById('fpsCounter').textContent = fps;
|
||||||
|
hlsFrameCount++;
|
||||||
|
document.getElementById('frameCounter').textContent = hlsFrameCount;
|
||||||
|
|
||||||
|
hlsLastFrameTime = now;
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Feed data to JSMpeg
|
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
|
||||||
if (player.demuxer && player.demuxer.write) {
|
// Native HLS support (Safari)
|
||||||
player.demuxer.write(uint8Array);
|
console.log('Using native HLS support');
|
||||||
}
|
video.src = hlsUrl;
|
||||||
}
|
video.addEventListener('loadedmetadata', function() {
|
||||||
};
|
console.log('HLS metadata loaded');
|
||||||
|
video.play();
|
||||||
|
});
|
||||||
|
|
||||||
websocket.onerror = (error) => {
|
video.addEventListener('playing', () => {
|
||||||
console.error('WebSocket error:', error);
|
document.getElementById('loadingOverlay').classList.add('hidden');
|
||||||
updateConnectionStatus(false);
|
updateConnectionStatus(true);
|
||||||
showError('Ошибка WebSocket соединения');
|
});
|
||||||
};
|
} else {
|
||||||
|
showError('HLS не поддерживается в этом браузере');
|
||||||
websocket.onclose = () => {
|
}
|
||||||
console.log('WebSocket disconnected');
|
|
||||||
updateConnectionStatus(false);
|
|
||||||
|
|
||||||
// Auto-reconnect
|
|
||||||
if (streamMode === 'websocket' && reconnectAttempts < MAX_RECONNECT_ATTEMPTS) {
|
|
||||||
reconnectAttempts++;
|
|
||||||
console.log(`Attempting to reconnect (${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS})...`);
|
|
||||||
setTimeout(connectWebSocket, 2000);
|
|
||||||
} else if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
|
|
||||||
showError('Потеряно соединение. Нажмите "Переподключиться"');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Start memory monitoring
|
// Start memory monitoring
|
||||||
if (!statsInterval) {
|
if (!statsInterval) {
|
||||||
@ -482,25 +463,16 @@
|
|||||||
updateMemoryUsage();
|
updateMemoryUsage();
|
||||||
}, 2000);
|
}, 2000);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send periodic ping to keep connection alive
|
|
||||||
const pingInterval = setInterval(() => {
|
|
||||||
if (websocket && websocket.readyState === WebSocket.OPEN) {
|
|
||||||
websocket.send('ping');
|
|
||||||
} else {
|
|
||||||
clearInterval(pingInterval);
|
|
||||||
}
|
|
||||||
}, 30000); // Every 30 seconds
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function disconnectWebSocket() {
|
function disconnectHLS() {
|
||||||
if (websocket) {
|
if (hls) {
|
||||||
websocket.close();
|
hls.destroy();
|
||||||
websocket = null;
|
hls = null;
|
||||||
}
|
}
|
||||||
if (player) {
|
if (video) {
|
||||||
player.destroy();
|
video.pause();
|
||||||
player = null;
|
video.src = '';
|
||||||
}
|
}
|
||||||
if (statsInterval) {
|
if (statsInterval) {
|
||||||
clearInterval(statsInterval);
|
clearInterval(statsInterval);
|
||||||
@ -510,7 +482,7 @@
|
|||||||
|
|
||||||
function connectSSE() {
|
function connectSSE() {
|
||||||
document.getElementById('streamMethod').textContent = 'SSE (JPEG)';
|
document.getElementById('streamMethod').textContent = 'SSE (JPEG)';
|
||||||
document.getElementById('videoCanvas').classList.add('hidden');
|
document.getElementById('videoPlayer').classList.add('hidden');
|
||||||
document.getElementById('videoFrame').classList.remove('hidden');
|
document.getElementById('videoFrame').classList.remove('hidden');
|
||||||
|
|
||||||
if (eventSource) {
|
if (eventSource) {
|
||||||
@ -635,9 +607,9 @@
|
|||||||
function reconnect() {
|
function reconnect() {
|
||||||
document.getElementById('loadingOverlay').classList.remove('hidden');
|
document.getElementById('loadingOverlay').classList.remove('hidden');
|
||||||
reconnectAttempts = 0;
|
reconnectAttempts = 0;
|
||||||
if (streamMode === 'websocket') {
|
if (streamMode === 'hls') {
|
||||||
disconnectWebSocket();
|
disconnectHLS();
|
||||||
connectWebSocket();
|
connectHLS();
|
||||||
} else {
|
} else {
|
||||||
disconnectSSE();
|
disconnectSSE();
|
||||||
connectSSE();
|
connectSSE();
|
||||||
@ -647,8 +619,13 @@
|
|||||||
function takeScreenshot() {
|
function takeScreenshot() {
|
||||||
let dataUrl;
|
let dataUrl;
|
||||||
|
|
||||||
if (streamMode === 'websocket') {
|
if (streamMode === 'hls') {
|
||||||
const canvas = document.getElementById('videoCanvas');
|
// Capture current frame from video element
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = video.videoWidth;
|
||||||
|
canvas.height = video.videoHeight;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
|
||||||
dataUrl = canvas.toDataURL('image/jpeg', 0.95);
|
dataUrl = canvas.toDataURL('image/jpeg', 0.95);
|
||||||
} else {
|
} else {
|
||||||
const img = document.getElementById('videoFrame');
|
const img = document.getElementById('videoFrame');
|
||||||
@ -661,16 +638,16 @@
|
|||||||
link.click();
|
link.click();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Connect on page load with default WebSocket mode
|
// Connect on page load with default HLS mode
|
||||||
if (streamMode === 'websocket') {
|
if (streamMode === 'hls') {
|
||||||
connectWebSocket();
|
connectHLS();
|
||||||
} else {
|
} else {
|
||||||
connectSSE();
|
connectSSE();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cleanup on page unload
|
// Cleanup on page unload
|
||||||
window.addEventListener('beforeunload', () => {
|
window.addEventListener('beforeunload', () => {
|
||||||
disconnectWebSocket();
|
disconnectHLS();
|
||||||
disconnectSSE();
|
disconnectSSE();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
Reference in New Issue
Block a user