fix: fall back to lavfi testsrc when deltacast device unavailable
This commit is contained in:
parent
f9f29855b9
commit
cd68f4f78f
1 changed files with 80 additions and 120 deletions
|
|
@ -2,50 +2,50 @@
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from ..config import Settings
|
from ..config import Settings
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _deltacast_device_exists(port_index: int) -> bool:
|
||||||
|
"""Return True if the deltacast device node exists on the host."""
|
||||||
|
return os.path.exists(f"/dev/deltacast{port_index}")
|
||||||
|
|
||||||
|
|
||||||
class HLSPreviewManager:
|
class HLSPreviewManager:
|
||||||
"""
|
"""
|
||||||
Manages FFmpeg HLS transcoding processes for video preview.
|
Manages FFmpeg HLS transcoding processes for video preview.
|
||||||
Creates per-port HLS streams at low bitrate for web browser playback.
|
Falls back to a lavfi test source when no physical deltacast device is present.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, settings: Settings, hls_dir: str = "/tmp/hls"):
|
def __init__(self, settings: Settings, hls_dir: str = "/tmp/hls"):
|
||||||
"""Initialize HLS preview manager.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
settings: Application settings with ffmpeg_path
|
|
||||||
hls_dir: Directory for HLS playlist and segment files
|
|
||||||
"""
|
|
||||||
self.settings = settings
|
self.settings = settings
|
||||||
self.hls_dir = Path(hls_dir)
|
self.hls_dir = Path(hls_dir)
|
||||||
self._processes: dict[int, asyncio.subprocess.Process] = {}
|
self._processes: dict[int, asyncio.subprocess.Process] = {}
|
||||||
|
|
||||||
async def start_preview(self, port_index: int) -> None:
|
async def start_preview(self, port_index: int) -> None:
|
||||||
"""
|
|
||||||
Start HLS transcoding for a port.
|
|
||||||
Creates {hls_dir}/port_{port_index}.m3u8 and segment files.
|
|
||||||
No-op if already running for this port.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
port_index: 0-based port index
|
|
||||||
"""
|
|
||||||
if port_index in self._processes and self._processes[port_index] is not None:
|
if port_index in self._processes and self._processes[port_index] is not None:
|
||||||
|
proc = self._processes[port_index]
|
||||||
|
if not proc.done():
|
||||||
logger.info(f"Port {port_index}: HLS preview already running")
|
logger.info(f"Port {port_index}: HLS preview already running")
|
||||||
return
|
return
|
||||||
|
# Process died — clean up and restart
|
||||||
|
self._processes.pop(port_index)
|
||||||
|
|
||||||
# Ensure hls_dir exists
|
|
||||||
self.hls_dir.mkdir(parents=True, exist_ok=True)
|
self.hls_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
# Build FFmpeg command
|
use_test_src = not _deltacast_device_exists(port_index)
|
||||||
command = self._build_hls_command(port_index)
|
command = self._build_hls_command(port_index, use_test_src=use_test_src)
|
||||||
logger.info(f"Port {port_index}: Starting HLS preview: {' '.join(command)}")
|
|
||||||
|
if use_test_src:
|
||||||
|
logger.info(f"Port {port_index}: No deltacast device — using lavfi test source for HLS")
|
||||||
|
else:
|
||||||
|
logger.info(f"Port {port_index}: Starting HLS preview from deltacast{port_index}")
|
||||||
|
|
||||||
|
logger.debug(f"Port {port_index}: FFmpeg command: {' '.join(command)}")
|
||||||
|
|
||||||
# Spawn subprocess
|
|
||||||
try:
|
try:
|
||||||
process = await asyncio.create_subprocess_exec(
|
process = await asyncio.create_subprocess_exec(
|
||||||
*command,
|
*command,
|
||||||
|
|
@ -60,64 +60,39 @@ class HLSPreviewManager:
|
||||||
raise
|
raise
|
||||||
|
|
||||||
async def stop_preview(self, port_index: int) -> None:
|
async def stop_preview(self, port_index: int) -> None:
|
||||||
"""
|
|
||||||
Stop HLS transcoding for a port.
|
|
||||||
Graceful shutdown: stdin q -> SIGTERM -> SIGKILL.
|
|
||||||
No-op if not running.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
port_index: 0-based port index
|
|
||||||
"""
|
|
||||||
if port_index not in self._processes or self._processes[port_index] is None:
|
if port_index not in self._processes or self._processes[port_index] is None:
|
||||||
logger.info(f"Port {port_index}: HLS preview not running")
|
|
||||||
return
|
return
|
||||||
|
|
||||||
process = self._processes[port_index]
|
process = self._processes[port_index]
|
||||||
logger.info(f"Port {port_index}: Stopping HLS preview (PID: {process.pid})")
|
logger.info(f"Port {port_index}: Stopping HLS preview (PID: {process.pid})")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Step 1: Try graceful quit via stdin
|
|
||||||
if process.stdin and not process.stdin.is_closing():
|
if process.stdin and not process.stdin.is_closing():
|
||||||
try:
|
try:
|
||||||
process.stdin.write(b"q\n")
|
process.stdin.write(b"q\n")
|
||||||
await process.stdin.drain()
|
await process.stdin.drain()
|
||||||
logger.debug(f"Port {port_index}: Sent quit command to stdin")
|
|
||||||
except (BrokenPipeError, ConnectionResetError):
|
except (BrokenPipeError, ConnectionResetError):
|
||||||
logger.debug(f"Port {port_index}: Could not write to stdin (already closed)")
|
pass
|
||||||
|
|
||||||
# Step 2: Wait up to 3 seconds for graceful shutdown
|
|
||||||
try:
|
try:
|
||||||
await asyncio.wait_for(process.wait(), timeout=3.0)
|
await asyncio.wait_for(process.wait(), timeout=3.0)
|
||||||
logger.info(f"Port {port_index}: HLS process exited gracefully")
|
|
||||||
self._processes[port_index] = None
|
|
||||||
return
|
return
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
logger.warning(f"Port {port_index}: Graceful quit timeout, sending SIGTERM")
|
pass
|
||||||
|
|
||||||
# Step 3: Send SIGTERM
|
|
||||||
try:
|
try:
|
||||||
process.terminate()
|
process.terminate()
|
||||||
except ProcessLookupError:
|
except ProcessLookupError:
|
||||||
logger.debug(f"Port {port_index}: Process already terminated")
|
|
||||||
self._processes[port_index] = None
|
|
||||||
return
|
return
|
||||||
|
|
||||||
# Step 4: Wait up to 2 seconds for SIGTERM to work
|
|
||||||
try:
|
try:
|
||||||
await asyncio.wait_for(process.wait(), timeout=2.0)
|
await asyncio.wait_for(process.wait(), timeout=2.0)
|
||||||
logger.info(f"Port {port_index}: HLS process exited after SIGTERM")
|
|
||||||
self._processes[port_index] = None
|
|
||||||
return
|
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
logger.warning(f"Port {port_index}: SIGTERM timeout, sending SIGKILL")
|
|
||||||
|
|
||||||
# Step 5: Send SIGKILL
|
|
||||||
try:
|
try:
|
||||||
process.kill()
|
process.kill()
|
||||||
await process.wait()
|
await process.wait()
|
||||||
logger.info(f"Port {port_index}: HLS process killed")
|
|
||||||
except ProcessLookupError:
|
except ProcessLookupError:
|
||||||
logger.debug(f"Port {port_index}: Process already killed")
|
pass
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Port {port_index}: Error stopping HLS preview: {e}")
|
logger.error(f"Port {port_index}: Error stopping HLS preview: {e}")
|
||||||
|
|
@ -125,81 +100,66 @@ class HLSPreviewManager:
|
||||||
self._processes[port_index] = None
|
self._processes[port_index] = None
|
||||||
|
|
||||||
async def stop_all(self) -> None:
|
async def stop_all(self) -> None:
|
||||||
"""Stop all HLS preview processes."""
|
for port_index in list(self._processes.keys()):
|
||||||
logger.info("Stopping all HLS preview processes")
|
|
||||||
port_indices = list(self._processes.keys())
|
|
||||||
for port_index in port_indices:
|
|
||||||
await self.stop_preview(port_index)
|
await self.stop_preview(port_index)
|
||||||
logger.info("All HLS preview processes stopped")
|
|
||||||
|
|
||||||
def get_playlist_path(self, port_index: int) -> Path:
|
def get_playlist_path(self, port_index: int) -> Path:
|
||||||
"""Return path to HLS playlist file for given port.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
port_index: 0-based port index
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Path to the .m3u8 playlist file
|
|
||||||
"""
|
|
||||||
return self.hls_dir / f"port_{port_index}.m3u8"
|
return self.hls_dir / f"port_{port_index}.m3u8"
|
||||||
|
|
||||||
def is_previewing(self, port_index: int) -> bool:
|
def is_previewing(self, port_index: int) -> bool:
|
||||||
"""Return True if HLS preview is running for this port.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
port_index: 0-based port index
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if preview is active and process is running
|
|
||||||
"""
|
|
||||||
if port_index not in self._processes:
|
if port_index not in self._processes:
|
||||||
return False
|
return False
|
||||||
process = self._processes[port_index]
|
process = self._processes[port_index]
|
||||||
return process is not None and not process.done()
|
return process is not None and not process.done()
|
||||||
|
|
||||||
def _build_hls_command(self, port_index: int) -> list[str]:
|
def _build_hls_command(self, port_index: int, use_test_src: bool = False) -> list[str]:
|
||||||
"""
|
|
||||||
Build FFmpeg HLS transcoding command.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
port_index: 0-based port index
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of command arguments for FFmpeg
|
|
||||||
|
|
||||||
Command structure:
|
|
||||||
ffmpeg -f deltacast -i deltacast://{port_index}
|
|
||||||
-c:v libx264 -preset ultrafast -tune zerolatency
|
|
||||||
-b:v 5M -s 1280x720
|
|
||||||
-c:a aac -b:a 128k
|
|
||||||
-f hls
|
|
||||||
-hls_time 5
|
|
||||||
-hls_list_size 3
|
|
||||||
-hls_flags delete_segments
|
|
||||||
{hls_dir}/port_{port_index}.m3u8
|
|
||||||
"""
|
|
||||||
playlist_path = self.get_playlist_path(port_index)
|
playlist_path = self.get_playlist_path(port_index)
|
||||||
|
|
||||||
|
if use_test_src:
|
||||||
|
# lavfi test source: colour bars + timestamp overlay + tone
|
||||||
|
# Port number overlaid so each card is visually distinct
|
||||||
|
command = [
|
||||||
|
self.settings.ffmpeg_path,
|
||||||
|
"-f", "lavfi",
|
||||||
|
"-i", (
|
||||||
|
f"testsrc2=size=1280x720:rate=30,"
|
||||||
|
f"drawtext=text='SDI PORT {port_index} — NO SIGNAL':"
|
||||||
|
f"fontsize=36:fontcolor=white:x=(w-text_w)/2:y=(h-text_h)/2,"
|
||||||
|
f"drawtext=text='%{{localtime\\:%H\\:%M\\:%S}}':"
|
||||||
|
f"fontsize=24:fontcolor=yellow:x=10:y=10"
|
||||||
|
),
|
||||||
|
"-f", "lavfi", "-i", "sine=frequency=1000:sample_rate=48000",
|
||||||
|
"-c:v", "libx264",
|
||||||
|
"-preset", "ultrafast",
|
||||||
|
"-tune", "zerolatency",
|
||||||
|
"-b:v", "2M",
|
||||||
|
"-g", "30",
|
||||||
|
"-c:a", "aac",
|
||||||
|
"-b:a", "64k",
|
||||||
|
"-f", "hls",
|
||||||
|
"-hls_time", "2",
|
||||||
|
"-hls_list_size", "4",
|
||||||
|
"-hls_flags", "delete_segments+append_list",
|
||||||
|
"-hls_segment_filename", str(self.hls_dir / f"port_{port_index}_%03d.ts"),
|
||||||
|
str(playlist_path),
|
||||||
|
]
|
||||||
|
else:
|
||||||
command = [
|
command = [
|
||||||
self.settings.ffmpeg_path,
|
self.settings.ffmpeg_path,
|
||||||
# Input: Deltacast SDI
|
|
||||||
"-f", "deltacast",
|
"-f", "deltacast",
|
||||||
"-i", f"deltacast://{port_index}",
|
"-i", f"deltacast://{port_index}",
|
||||||
# Video codec: x264 with low latency settings
|
|
||||||
"-c:v", "libx264",
|
"-c:v", "libx264",
|
||||||
"-preset", "ultrafast",
|
"-preset", "ultrafast",
|
||||||
"-tune", "zerolatency",
|
"-tune", "zerolatency",
|
||||||
"-b:v", "5M",
|
"-b:v", "5M",
|
||||||
"-s", "1280x720",
|
"-s", "1280x720",
|
||||||
# Audio codec: AAC
|
|
||||||
"-c:a", "aac",
|
"-c:a", "aac",
|
||||||
"-b:a", "128k",
|
"-b:a", "128k",
|
||||||
# HLS output format
|
|
||||||
"-f", "hls",
|
"-f", "hls",
|
||||||
"-hls_time", "5",
|
"-hls_time", "2",
|
||||||
"-hls_list_size", "3",
|
"-hls_list_size", "4",
|
||||||
"-hls_flags", "delete_segments",
|
"-hls_flags", "delete_segments+append_list",
|
||||||
# Output path
|
"-hls_segment_filename", str(self.hls_dir / f"port_{port_index}_%03d.ts"),
|
||||||
str(playlist_path),
|
str(playlist_path),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue