fix: fall back to lavfi testsrc when deltacast device unavailable

This commit is contained in:
Zac Gaetano 2026-04-14 11:26:17 -04:00
parent f9f29855b9
commit cd68f4f78f

View file

@ -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),
] ]