Files
XC_VM/tools/test-stream-generator/stream_server.py
T

695 lines
28 KiB
Python
Raw Normal View History

#!/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 = """<!doctype html>
<html><head><meta charset="utf-8"><title>XC_VM test stream generator</title>
<style>body{{font-family:system-ui,sans-serif;max-width:760px;margin:40px auto;padding:0 16px}}
code{{background:#f3f3f3;padding:2px 6px;border-radius:4px}}
li{{margin:8px 0}}</style></head><body>
<h1>XC_VM test stream generator</h1>
<p>Source: <b>generated</b> ({size} @ {fps}fps) &mdash; moving test pattern with a
live stopwatch, wall-clock and sweeping boxes. No input file.</p>
<p>Font: <code>{font}</code></p>
<h2>Stream URLs</h2>
<ul>
<li><b>MPEG-TS (for LLOD)</b>: <a href="{base}/stream.ts"><code>{base}/stream.ts</code></a><br>
Paste into the stream source field, enable <b>LLOD</b>.</li>
<li><b>HLS</b>: <a href="{base}/stream.m3u8"><code>{base}/stream.m3u8</code></a><br>
Paste into the stream source field for a normal live stream.</li>
<li><b>M3U channel list</b>: <a href="{base}/playlist.m3u"><code>{base}/playlist.m3u</code></a><br>
Import as a playlist to add both channels at once.</li>
</ul>
<p>Add the URL in the admin panel under the stream's
<code>stream_source[]</code> field (Streams &rarr; Add/Edit).</p>
</body></html>
""".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()