#!/usr/bin/env python3 """ XC_VM test stream generator. Generates a synthetic, ever-changing test pattern (NO input file required) and serves it as looping, HTTP-served streams that can be pasted straight into the panel as a live stream source: /stream.ts Continuous MPEG-TS (Content-Type: video/mp2t). This is the endpoint for testing LLOD (the panel's Low-Latency On-Demand processor, src/Cli/Commands/LlodCommand.php, which validates the upstream Content-Type is video/mp2t). /stream.m3u8 Live HLS playlist + rolling .ts segments (sliding window). Use as a normal live-stream source. /playlist.m3u M3U channel list referencing the URLs above, for bulk import. / Human-readable index listing every URL. The picture is generated live by ffmpeg from `testsrc2` (a moving colour test pattern) with overlays: a large running STOPWATCH (elapsed), the real WALL-CLOCK time (handy for measuring end-to-end latency — compare the on-screen time to your own clock), a frame counter, and two boxes sweeping across the frame. Audio is a low 1 kHz tone. Everything is paced in real time (-re), so the panel sees a never-ending "live" channel. Only the Python 3 standard library + ffmpeg are required (no pip packages, no media files). If no usable TrueType font is found, it falls back to `testsrc` (v1), whose built-in timestamp still gives a running timer plus the moving boxes. Examples: ./stream_server.py ./stream_server.py --host 0.0.0.0 --port 8088 ./stream_server.py --size 1920x1080 --fps 30 ./stream_server.py --font /usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf Stop with Ctrl+C. """ import argparse import atexit import collections import os import queue import re import shutil import signal import socket import subprocess import sys import tempfile import threading import time from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer # MPEG-TS packets are 188 bytes; read/forward in packet-aligned chunks. TS_PACKET = 188 TS_CHUNK = TS_PACKET * 64 # ~12 KiB # Long-run safety: cap concurrent /stream.ts readers (they all share ONE encoder # now, so this bounds threads/FDs/memory, not ffmpeg processes) and bound socket # writes so a stalled/half-open client cannot pin resources. Both CLI-overridable. DEFAULT_MAX_TS_CLIENTS = 32 TS_WRITE_TIMEOUT = 30 # seconds a write may stall before the client is dropped # Shared MPEG-TS fan-out. One persistent ffmpeg feeds every /stream.ts client, so # opening the channel (including repeated panel on-demand pulls) never spawns a # fresh encoder — there are always exactly TWO encoders, one HLS and one TS, # started at boot — and a joining client attaches to the live byte stream with no # cold-start latency. TS_KEYFRAME_TIME = 0.4 # seconds between keyframes on the TS encoder. # Small on purpose: a consumer that joins the # shared stream mid-GOP (e.g. the panel's LLOD # probe with analyzeduration=0.5s) must find an # IDR + SPS/PPS within its analysis window, or it # reports "dimensions not set" and never starts. TS_PREBUFFER_BYTES = 4 * 1024 * 1024 # recent bytes replayed to a joiner (spans >=1 keyframe → instant decode) TS_CLIENT_QUEUE = 1024 # per-client backlog chunks before a slow reader is dropped # Sliding-window HLS so the playlist never grows unbounded. HLS_SEGMENT_TIME = 4 HLS_LIST_SIZE = 6 # 1 kHz test tone (like SMPTE bars), kept quiet. AUDIO_SRC = "sine=frequency=1000:sample_rate=48000" AUDIO_VOLUME = "0.2" # Common TrueType font locations (Linux distros + macOS) probed when the user # does not pass --font. First match wins. FONT_CANDIDATES = [ "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", "/usr/share/fonts/dejavu/DejaVuSans-Bold.ttf", "/usr/share/fonts/dejavu/DejaVuSans.ttf", "/usr/share/fonts/TTF/DejaVuSans.ttf", "/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf", "/usr/share/fonts/liberation/LiberationSans-Regular.ttf", "/usr/share/fonts/truetype/freefont/FreeSans.ttf", "/Library/Fonts/Arial.ttf", "/System/Library/Fonts/Supplemental/Arial.ttf", ] CONFIG = {} # populated in main() # --------------------------------------------------------------------------- # # ffmpeg command builders (synthetic source) # --------------------------------------------------------------------------- # def _base_video(): """lavfi video source spec. testsrc2 (rich moving pattern) when a font is available for overlays; testsrc (v1, which draws its own timestamp) as the font-less fallback so there is always a visible running timer.""" src = "testsrc2" if CONFIG.get("font") else "testsrc" return "%s=size=%s:rate=%d" % (src, CONFIG["size"], CONFIG["fps"]) def _vf(label): """Build the -vf overlay chain. The moving boxes need no font; the clocks do. Single quotes around any value that contains ``:`` or ``,`` keep ffmpeg from treating them as option/filter separators — the command is exec'd directly (no shell), so only ffmpeg-level escaping is required. """ # Two boxes sweeping across the frame — pure motion, font-independent. boxes = [ "drawbox=x='mod(t*260,iw)':y=ih-70:w=120:h=36:color=red@0.9:thickness=fill", "drawbox=y='mod(t*150,ih)':x=24:w=36:h=120:color=cyan@0.9:thickness=fill", ] if not CONFIG.get("font"): # testsrc (v1) already renders a timestamp + frame number. return ",".join(boxes) tf = "fontfile=" + CONFIG["font"] + ":" meta = CONFIG["size"] + " @ " + str(CONFIG["fps"]) + "fps" texts = [ "drawtext=" + tf + "text='XC_VM TEST - " + label + "'" ":x=(w-text_w)/2:y=36:fontsize=44:fontcolor=white" ":box=1:boxcolor=black@0.55:boxborderw=12", # Real wall-clock time — compare to your own clock to gauge latency. "drawtext=" + tf + "text='%{localtime}'" ":x=(w-text_w)/2:y=104:fontsize=34:fontcolor=0xFFD400" ":box=1:boxcolor=black@0.55:boxborderw=8", # Big stopwatch: elapsed time since this ffmpeg (stream) started. "drawtext=" + tf + "text='ELAPSED %{pts:hms}'" ":x=(w-text_w)/2:y=(h-text_h)/2:fontsize=72:fontcolor=0x00FF66" ":box=1:boxcolor=black@0.6:boxborderw=16", "drawtext=" + tf + "text='frame %{n} " + meta + "'" ":x=(w-text_w)/2:y=h-150:fontsize=26:fontcolor=white" ":box=1:boxcolor=black@0.55:boxborderw=6", ] return ",".join(texts + boxes) def _encode(keyint_seconds=None): """Encode args (the synthetic source is raw, so it is always re-encoded). A `keyint_seconds` pins a regular keyframe cadence: HLS needs it to cut clean segments; the shared TS encoder needs it so a client joining the live fan-out finds a keyframe in the prebuffer and starts decoding immediately.""" args = [ "-c:v", "libx264", "-preset", "veryfast", "-tune", "zerolatency", "-pix_fmt", "yuv420p", "-c:a", "aac", "-b:a", "128k", "-ar", "48000", ] if keyint_seconds: args += ["-g", str(max(1, int(keyint_seconds * CONFIG["fps"]))), "-force_key_frames", "expr:gte(t,n_forced*%s)" % keyint_seconds] return args def _source_inputs(): return [ "-f", "lavfi", "-i", _base_video(), "-f", "lavfi", "-i", AUDIO_SRC, ] def build_ts_cmd(label="TS - LLOD"): """The single, always-running MPEG-TS encoder to stdout (pipe:1), fanned out to every /stream.ts client by TsBroadcaster. Frequent keyframes + a short PAT/PMT period (-pat_period 1) let a joining client lock on almost instantly from the prebuffer.""" return [ CONFIG["ffmpeg"], "-hide_banner", "-loglevel", "error", "-re", *_source_inputs(), "-vf", _vf(label), "-af", "volume=" + AUDIO_VOLUME, *_encode(keyint_seconds=TS_KEYFRAME_TIME), "-mpegts_flags", "+initial_discontinuity", "-pat_period", "1", "-f", "mpegts", "pipe:1", ] def build_hls_cmd(hls_dir): """Background HLS writer: live sliding-window playlist + segments.""" return [ CONFIG["ffmpeg"], "-hide_banner", "-loglevel", "error", "-re", *_source_inputs(), "-vf", _vf("HLS"), "-af", "volume=" + AUDIO_VOLUME, *_encode(keyint_seconds=HLS_SEGMENT_TIME), "-f", "hls", "-hls_time", str(HLS_SEGMENT_TIME), "-hls_list_size", str(HLS_LIST_SIZE), "-hls_flags", "delete_segments+omit_endlist+independent_segments", "-hls_segment_type", "mpegts", "-hls_segment_filename", os.path.join(hls_dir, "seg-%06d.ts"), os.path.join(hls_dir, "stream.m3u8"), ] # --------------------------------------------------------------------------- # # Background HLS writer with auto-restart # --------------------------------------------------------------------------- # class HlsWriter: """Keeps a single ffmpeg process producing the live HLS output alive.""" def __init__(self, hls_dir): self.hls_dir = hls_dir self.proc = None self._stop = threading.Event() self._thread = threading.Thread(target=self._run, daemon=True) def start(self): self._thread.start() def _run(self): while not self._stop.is_set(): self.proc = subprocess.Popen( build_hls_cmd(self.hls_dir), stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, ) _, err = self.proc.communicate() if self._stop.is_set(): return sys.stderr.write( "[hls] ffmpeg exited (code %s), restarting in 2s\n%s\n" % (self.proc.returncode, (err or b"").decode("utf-8", "replace").strip()) ) time.sleep(2) def stop(self): self._stop.set() if self.proc and self.proc.poll() is None: self.proc.terminate() try: self.proc.wait(timeout=5) except subprocess.TimeoutExpired: self.proc.kill() # --------------------------------------------------------------------------- # # Shared MPEG-TS broadcaster (one encoder, fanned out to every client) # --------------------------------------------------------------------------- # class TsBroadcaster: """Runs ONE persistent ffmpeg producing continuous MPEG-TS and fans its output out to every connected /stream.ts client. Opening the channel never spawns a new encoder, so there is no per-connection cold start and the process count stays fixed (one TS + one HLS) no matter how many clients or repeated panel on-demand pulls arrive. Each client gets its own bounded queue; a joiner is primed with the recent prebuffer (which spans at least one keyframe) so it decodes at once. A reader that falls too far behind is dropped rather than stalling the encoder or the other clients. """ def __init__(self): self.proc = None self._stop = threading.Event() self._lock = threading.Lock() self._clients = set() # set[queue.Queue] self._prebuffer = collections.deque() # recent chunks for instant join self._prebuffer_bytes = 0 self._thread = threading.Thread(target=self._run, daemon=True) def start(self): self._thread.start() # ---- encoder lifecycle ------------------------------------------------ # def _run(self): while not self._stop.is_set(): # stderr → DEVNULL: the identical HLS encoder captures/surfaces any # ffmpeg error, and a persistent unread stderr pipe could otherwise # fill and stall the encoder. self.proc = subprocess.Popen( build_ts_cmd(), stdout=subprocess.PIPE, stderr=subprocess.DEVNULL ) try: while not self._stop.is_set(): chunk = self.proc.stdout.read(TS_CHUNK) if not chunk: break self._publish(chunk) finally: if self.proc.poll() is None: self.proc.terminate() try: self.proc.wait(timeout=5) except subprocess.TimeoutExpired: self.proc.kill() if self._stop.is_set(): return sys.stderr.write("[ts] encoder exited (code %s), restarting in 2s\n" % self.proc.returncode) time.sleep(2) # ---- fan-out ---------------------------------------------------------- # def _publish(self, chunk): with self._lock: self._prebuffer.append(chunk) self._prebuffer_bytes += len(chunk) while self._prebuffer_bytes > TS_PREBUFFER_BYTES and len(self._prebuffer) > 1: self._prebuffer_bytes -= len(self._prebuffer.popleft()) dead = [] for q in self._clients: try: q.put_nowait(chunk) except queue.Full: dead.append(q) for q in dead: self._clients.discard(q) # make room + wake the client's reader thread so it exits cleanly try: q.get_nowait() except queue.Empty: pass try: q.put_nowait(None) except queue.Full: pass def add_client(self): """Register a client; return (queue, prime_bytes) where prime_bytes is the current prebuffer to send first for an immediate lock-on.""" q = queue.Queue(maxsize=TS_CLIENT_QUEUE) with self._lock: prime = b"".join(self._prebuffer) self._clients.add(q) return q, prime def remove_client(self, q): with self._lock: self._clients.discard(q) def client_count(self): with self._lock: return len(self._clients) def stop(self): self._stop.set() if self.proc and self.proc.poll() is None: self.proc.terminate() try: self.proc.wait(timeout=5) except subprocess.TimeoutExpired: self.proc.kill() with self._lock: for q in self._clients: try: q.put_nowait(None) except queue.Full: pass self._clients.clear() # --------------------------------------------------------------------------- # # HTTP handler # --------------------------------------------------------------------------- # class Handler(BaseHTTPRequestHandler): protocol_version = "HTTP/1.1" server_version = "XC_VM-TestStream/2.0" # ---- helpers ---------------------------------------------------------- # def _base_url(self): host = self.headers.get("Host") or "%s:%d" % (CONFIG["advertise_host"], CONFIG["port"]) return "http://%s" % host def _send_text(self, body, content_type="text/plain; charset=utf-8", status=200): data = body.encode("utf-8") if isinstance(body, str) else body self.send_response(status) self.send_header("Content-Type", content_type) self.send_header("Content-Length", str(len(data))) self.send_header("Cache-Control", "no-cache") self.end_headers() if self.command != "HEAD": self.wfile.write(data) def _serve_hls_file(self, name): # basename() guards against path traversal. path = os.path.join(CONFIG["hls_dir"], os.path.basename(name)) if not os.path.isfile(path): self._send_text("Not ready yet, retry in a moment.\n", status=404) return ctype = "application/vnd.apple.mpegurl" if name.endswith(".m3u8") else "video/mp2t" try: with open(path, "rb") as fh: data = fh.read() except OSError: self._send_text("Not ready yet, retry in a moment.\n", status=404) return self.send_response(200) self.send_header("Content-Type", ctype) self.send_header("Content-Length", str(len(data))) self.send_header("Cache-Control", "no-cache") self.end_headers() if self.command != "HEAD": self.wfile.write(data) def _serve_playlist_m3u(self): base = self._base_url() lines = [ "#EXTM3U", '#EXTINF:-1 tvg-id="xcvm.test.ts" tvg-name="XC_VM Test TS (LLOD)" ' 'group-title="XC_VM Test",XC_VM Test TS (LLOD)', "%s/stream.ts" % base, '#EXTINF:-1 tvg-id="xcvm.test.hls" tvg-name="XC_VM Test HLS" ' 'group-title="XC_VM Test",XC_VM Test HLS', "%s/stream.m3u8" % base, "", ] self._send_text("\n".join(lines), content_type="audio/x-mpegurl") def _stream_ts(self): """Continuous MPEG-TS — the LLOD-compatible endpoint. Served from the single shared TsBroadcaster: the client attaches to the always-running encoder's live byte stream (primed with a keyframe-aligned prebuffer), so opening the channel is instant and never spawns a new ffmpeg — no matter how many clients or repeated panel on-demand pulls connect. Long-run hardening: a bounded number of concurrent readers, a socket write timeout + TCP keepalive so a stalled/half-open client is dropped instead of holding a slot, and a bounded per-client queue so a slow reader is dropped instead of growing memory.""" slots = CONFIG["ts_slots"] if not slots.acquire(blocking=False): sys.stderr.write("[ts] refused %s: at reader cap (%d)\n" % (self.address_string(), CONFIG["max_clients"])) self._send_text("Too many concurrent streams, try later.\n", status=503) return bcast = CONFIG["ts_broadcaster"] q = None try: self.send_response(200) self.send_header("Content-Type", "video/mp2t") self.send_header("Cache-Control", "no-cache") self.send_header("Connection", "close") self.end_headers() if self.command == "HEAD": return # Drop a stalled/half-open client instead of blocking forever on a # write: keepalive surfaces dead peers, settimeout bounds each send. try: self.connection.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1) except OSError: pass self.connection.settimeout(TS_WRITE_TIMEOUT) q, prime = bcast.add_client() sys.stderr.write("[ts] join %s (readers=%d)\n" % (self.address_string(), bcast.client_count())) if prime: self.wfile.write(prime) while True: chunk = q.get() if chunk is None: break # dropped (too slow) or server shutting down self.wfile.write(chunk) except (BrokenPipeError, ConnectionResetError, ConnectionAbortedError, socket.timeout, TimeoutError, OSError): pass # client disconnected or stalled past the write timeout — expected finally: if q is not None: bcast.remove_client(q) slots.release() sys.stderr.write("[ts] leave %s\n" % self.address_string()) def _index(self): base = self._base_url() font = CONFIG.get("font") or "(none — testsrc fallback)" html = """ XC_VM test stream generator

XC_VM test stream generator

Source: generated ({size} @ {fps}fps) — moving test pattern with a live stopwatch, wall-clock and sweeping boxes. No input file.

Font: {font}

Stream URLs

Add the URL in the admin panel under the stream's stream_source[] field (Streams → Add/Edit).

""".format(base=base, size=CONFIG["size"], fps=CONFIG["fps"], font=font) self._send_text(html, content_type="text/html; charset=utf-8") # ---- routing ---------------------------------------------------------- # def _route(self): path = self.path.split("?", 1)[0] if path == "/" or path == "/index.html": self._index() elif path == "/stream.ts": self._stream_ts() elif path == "/stream.m3u8": self._serve_hls_file("stream.m3u8") elif path == "/playlist.m3u": self._serve_playlist_m3u() elif path.startswith("/seg-") and path.endswith(".ts"): self._serve_hls_file(path[1:]) else: self._send_text("Not found\n", status=404) def do_GET(self): self._route() def do_HEAD(self): self._route() def log_message(self, fmt, *args): # Per-request access logging is off by default: an HLS player polls the # playlist + segments every few seconds, so over days this floods the # log (and any file it is redirected to). Meaningful events (stream # start/stop, refusals, hls restarts) are logged explicitly elsewhere. if CONFIG.get("verbose"): sys.stderr.write("[http] %s - %s\n" % (self.address_string(), fmt % args)) # --------------------------------------------------------------------------- # # main # --------------------------------------------------------------------------- # def parse_args(): p = argparse.ArgumentParser( description="Serve a generated moving test pattern as TS/HLS/M3U streams for XC_VM testing." ) p.add_argument("--host", default="0.0.0.0", help="bind address (default 0.0.0.0)") p.add_argument("--port", type=int, default=8088, help="bind port (default 8088)") p.add_argument( "--advertise-host", default=None, help="host/IP to print in URLs (default: autodetected LAN IP)", ) p.add_argument("--size", default="1280x720", help="frame size WxH (default 1280x720)") p.add_argument("--fps", type=int, default=25, help="frame rate (default 25)") p.add_argument( "--font", default=None, help="path to a .ttf for the on-screen clock/labels " "(default: autodetect; falls back to testsrc's built-in timer)", ) p.add_argument("--ffmpeg", default="ffmpeg", help="ffmpeg binary (default: ffmpeg in PATH)") p.add_argument( "--max-clients", type=int, default=DEFAULT_MAX_TS_CLIENTS, help="max concurrent /stream.ts readers, all sharing one encoder (default %d)" % DEFAULT_MAX_TS_CLIENTS, ) p.add_argument( "--verbose", action="store_true", help="log every HTTP request (off by default to keep long runs quiet)", ) return p.parse_args() def detect_font(explicit): """Return a usable .ttf path, or None. Prefers --font; then common paths; then anything fontconfig can point at (fc-match).""" if explicit: return explicit if os.path.isfile(explicit) else None for cand in FONT_CANDIDATES: if os.path.isfile(cand): return cand try: out = subprocess.check_output( ["fc-match", "-f", "%{file}", "sans"], stderr=subprocess.DEVNULL ).decode("utf-8", "replace").strip() if out and os.path.isfile(out): return out except Exception: pass return None def detect_lan_ip(): s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) try: s.connect(("8.8.8.8", 80)) return s.getsockname()[0] except OSError: return "127.0.0.1" finally: s.close() class QuietThreadingHTTPServer(ThreadingHTTPServer): """Threaded HTTP server that ignores expected client disconnects. The panel's ffmpeg (probe/LLOD) reads part of a stream then resets the connection — the stdlib server would otherwise print a full traceback per disconnect. Those are normal, so swallow them and keep the log clean. """ daemon_threads = True def handle_error(self, request, client_address): exc = sys.exc_info()[1] if isinstance(exc, (ConnectionResetError, BrokenPipeError, ConnectionAbortedError, TimeoutError)): return # client (panel) disconnected mid-request — expected super().handle_error(request, client_address) def main(): args = parse_args() if shutil.which(args.ffmpeg) is None and not os.path.isfile(args.ffmpeg): sys.exit("error: ffmpeg not found (looked for %r). Install it or pass --ffmpeg." % args.ffmpeg) if not re.fullmatch(r"\d+x\d+", args.size): sys.exit("error: --size must be WxH (e.g. 1280x720), got %r" % args.size) if args.fps <= 0: sys.exit("error: --fps must be positive") if args.max_clients < 1: sys.exit("error: --max-clients must be >= 1") if args.font and not os.path.isfile(args.font): sys.stderr.write("warning: --font %r not found; falling back.\n" % args.font) font = detect_font(args.font) hls_dir = tempfile.mkdtemp(prefix="xcvm-teststream-") atexit.register(lambda: shutil.rmtree(hls_dir, ignore_errors=True)) CONFIG.update({ "host": args.host, "port": args.port, "advertise_host": args.advertise_host or detect_lan_ip(), "size": args.size, "fps": args.fps, "font": font, "ffmpeg": args.ffmpeg, "hls_dir": hls_dir, "verbose": args.verbose, "max_clients": args.max_clients, "ts_slots": threading.BoundedSemaphore(args.max_clients), }) writer = HlsWriter(hls_dir) writer.start() # Second, always-running encoder: the shared MPEG-TS source. Started at boot # (not per client) so /stream.ts joins are instant and the ffmpeg count is # fixed at two. ts_bcast = TsBroadcaster() CONFIG["ts_broadcaster"] = ts_bcast ts_bcast.start() httpd = QuietThreadingHTTPServer((args.host, args.port), Handler) base = "http://%s:%d" % (CONFIG["advertise_host"], args.port) print("XC_VM test stream generator") print(" source: generated %s @ %dfps (moving pattern + stopwatch + wall-clock)" % (args.size, args.fps)) print(" font : %s" % (font or "(none — testsrc built-in timer)")) print(" bind : %s:%d (2 encoders: 1 HLS + 1 shared TS, max %d TS readers)" % (args.host, args.port, args.max_clients)) print("") print("Paste one of these into the panel's stream source field:") print(" TS (LLOD): %s/stream.ts" % base) print(" HLS : %s/stream.m3u8" % base) print(" M3U list : %s/playlist.m3u" % base) print(" Index : %s/" % base) print("") print("Ctrl+C to stop.") def shutdown(*_): print("\nstopping...") writer.stop() ts_bcast.stop() threading.Thread(target=httpd.shutdown, daemon=True).start() signal.signal(signal.SIGINT, shutdown) signal.signal(signal.SIGTERM, shutdown) try: httpd.serve_forever() finally: httpd.server_close() writer.stop() ts_bcast.stop() if __name__ == "__main__": main()