Files
spoopyghosty0--frameport/tests/test_livestream.py
T

496 lines
20 KiB
Python

"""Live view: the fragmented-MP4 relay (box splitting, keyframe detection, late viewers), the player page, and the
Frame-side script (device lookup, stops when the SSH channel closes)."""
import io
import shutil
import struct
import subprocess
import time
import urllib.request
import pytest
from frameport.install import livestream as L
def box(kind: str, payload: bytes) -> bytes:
return struct.pack(">I4s", 8 + len(payload), kind.encode()) + payload
def full(kind: str, flags: int, payload: bytes) -> bytes:
return box(kind, struct.pack(">I", flags) + payload) # version 0
def init_segment(w=1280, h=720) -> bytes:
# avc1 sample entry: 6 reserved + 2 data ref + 16 pre_defined/reserved, then width/height; avcC: version,
# profile 0x42, compat 0xc0, level 0x1f
avcc = box("avcC", bytes([1, 0x42, 0xC0, 0x1F]) + b"\0" * 4)
avc1 = box("avc1", b"\0" * 24 + struct.pack(">HH", w, h) + b"\0" * 50 + avcc)
return box("ftyp", b"isom\0\0\0\0") + box("moov", box("trak", avc1))
def fragment(key: bool, how: str = "first", seq: int = 0) -> bytes:
flags = 0 if key else L.SAMPLE_NON_SYNC
if how == "first": # trun first_sample_flags (what ffmpeg writes)
trun = full("trun", 0x1 | 0x4, struct.pack(">IiI", 2, 0, flags))
tfhd = full("tfhd", 0, struct.pack(">I", 1))
elif how == "sample": # per-sample flags
trun = full("trun", 0x400, struct.pack(">II", 1, flags))
tfhd = full("tfhd", 0, struct.pack(">I", 1))
else: # tfhd default flags
trun = full("trun", 0, struct.pack(">I", 1))
tfhd = full("tfhd", 0x20, struct.pack(">II", 1, flags))
return box("moof", box("mfhd", struct.pack(">II", 0, seq)) + box("traf", tfhd + trun)) + \
box("mdat", bytes([seq % 256]) * 32)
@pytest.mark.parametrize("how", ["first", "sample", "default"])
def test_keyframe_detection(how):
assert L.fragment_is_keyframe(fragment(True, how))
assert not L.fragment_is_keyframe(fragment(False, how))
def test_keyframe_unknown_is_not_key():
assert not L.fragment_is_keyframe(box("moof", box("mfhd", b"\0" * 8)))
assert not L.fragment_is_keyframe(b"junk")
def test_codec_and_size():
init = init_segment(1920, 1080)
assert L.codec_string(init) == "avc1.42c01f"
assert L.video_size(init) == (1920, 1080)
assert L.codec_string(b"nothing") is None
def test_splitter_any_read_size():
stream = init_segment() + fragment(True, seq=1) + fragment(False, seq=2) + box("free", b"x")
for step in (1, 7, 64, len(stream)):
sp, out = L.Mp4Splitter(), []
for i in range(0, len(stream), step):
out += sp.feed(stream[i:i + step])
assert [k for k, _ in out] == ["init", "fragment", "fragment"]
assert out[0][1] == init_segment()
assert out[1][1] == fragment(True, seq=1)
def test_splitter_rejects_garbage():
with pytest.raises(ValueError):
L.Mp4Splitter().feed(struct.pack(">I4s", 3, b"bad!"))
def test_relay_late_viewer_starts_at_keyframe():
r = L.Relay()
r.feed(init_segment() + fragment(True, seq=1) + fragment(False, seq=2) + fragment(True, seq=3)
+ fragment(False, seq=4))
init, gop, q = r.subscribe()
assert init == init_segment()
assert gop == [fragment(True, seq=3), fragment(False, seq=4)]
r.feed(fragment(False, seq=5))
assert q.get_nowait() == fragment(False, seq=5)
st = r.status()
assert st["ready"] and st["codec"] == "avc1.42c01f" and st["width"] == 1280 and st["viewers"] == 1
r.finish("gone")
assert q.get_nowait() is None and r.status()["ended"] == "gone"
def test_relay_drops_slow_viewer(monkeypatch):
monkeypatch.setattr(L, "CLIENT_QUEUE", 2)
r = L.Relay()
r.feed(init_segment())
_, _, q = r.subscribe()
for i in range(3):
r.feed(fragment(True, seq=i))
assert r.status()["viewers"] == 0 # dropped; its page reconnects at a keyframe
class _Source:
"""A byte stream fed at the pace a test chooses."""
def __init__(self, data: bytes):
self.f = io.BytesIO(data)
def read(self, n):
time.sleep(0.01)
return self.f.read(min(n, 200))
def test_server_serves_page_status_and_stream():
data = init_segment() + b"".join(fragment(i % 3 == 0, seq=i) for i in range(6))
live = L.LiveStream(lambda: _Source(data)).start()
try:
page = urllib.request.urlopen(live.url, timeout=5).read()
assert b"MediaSource" in page and b"stream.mp4" in page
deadline = time.time() + 5
while live.relay.ended is None and time.time() < deadline:
time.sleep(0.05)
import json
st = json.loads(urllib.request.urlopen(live.url + "status", timeout=5).read())
assert st["ready"] and st["fragments"] == 6 and st["ended"]
with pytest.raises(urllib.error.HTTPError): # source over: no stream to join (503 isn't hung)
urllib.request.urlopen(live.url + "stream.mp4", timeout=5).read()
finally:
live.stop()
assert not live.running
def test_stream_endpoint_sends_init_then_gop():
gate = []
class Slow:
def __init__(self):
self.parts = [init_segment(), fragment(True, seq=1), fragment(False, seq=2)]
def read(self, n):
if self.parts:
return self.parts.pop(0)
while not gate:
time.sleep(0.02)
return b""
live = L.LiveStream(Slow).start()
try:
deadline = time.time() + 5
while live.relay.status()["fragments"] < 2 and time.time() < deadline:
time.sleep(0.02)
resp = urllib.request.urlopen(live.url + "stream.mp4", timeout=5)
assert resp.headers["Content-Type"] == "video/mp4"
gate.append(1)
body = resp.read()
assert body == init_segment() + fragment(True, seq=1) + fragment(False, seq=2)
finally:
live.stop()
def test_source_command_scales_down_only():
c720 = L.source_command("720p")
assert "scale=-2:'min(720,ih)'" in c720 and "fps=30" in c720 and '"SteamVR"' in c720
assert c720.index("fps=30") < c720.index("scale=") # drop frames before the expensive scale
for q, (h, _) in L.QUALITY.items():
assert ("scale=" in L.source_command(q)) == (h is not None)
assert L.source_command("bogus") == L.source_command(L.DEFAULT_QUALITY)
@pytest.mark.skipif(not shutil.which("bash"), reason="needs bash")
def test_source_command_without_device_exits_3(tmp_path):
script = L.source_command().replace("/sys/class/video4linux", str(tmp_path))
p = subprocess.run(["bash", "-c", script], capture_output=True, text=True, timeout=10, stdin=subprocess.DEVNULL)
assert p.returncode == 3 and "SteamVR" in p.stderr
@pytest.mark.skipif(not shutil.which("bash"), reason="needs bash")
def test_source_command_stops_when_stdin_closes(tmp_path):
"""The SSH channel closing = EOF on stdin: the encoder must stop (it would keep the Frame busy)."""
dev = tmp_path / "video99"
dev.mkdir()
(dev / "name").write_text("SteamVR\n")
bindir = tmp_path / "bin"
bindir.mkdir()
fake = bindir / "ffmpeg"
fake.write_text("#!/bin/sh\nexec sleep 30\n")
fake.chmod(0o755)
script = L.source_command().replace("/sys/class/video4linux", str(tmp_path))
p = subprocess.Popen(["bash", "-c", script], stdin=subprocess.PIPE, stdout=subprocess.PIPE,
env={"PATH": f"{bindir}:/usr/bin:/bin"})
time.sleep(0.5)
assert p.poll() is None # streaming
p.stdin.close()
assert p.wait(timeout=5) is not None
def trak(track_id: int, handler: bytes, entry: bytes) -> bytes:
tkhd = full("tkhd", 3, b"\0" * 8 + struct.pack(">I", track_id) + b"\0" * 68)
hdlr = full("hdlr", 0, b"\0" * 4 + handler + b"\0" * 13)
return box("trak", tkhd + box("mdia", hdlr + box("minf", box("stbl", box("stsd", entry)))))
def av_init() -> bytes:
avc1 = box("avc1", b"\0" * 24 + struct.pack(">HH", 1280, 720) + b"\0" * 50
+ box("avcC", bytes([1, 0x42, 0xC0, 0x1F]) + b"\0" * 4))
return box("ftyp", b"isom\0\0\0\0") + box("moov", trak(2, b"soun", box("mp4a", b"\0" * 28))
+ trak(1, b"vide", avc1))
def av_fragment(video_key: bool, seq: int) -> bytes:
"""Audio traf first (all sync samples), then the video traf."""
def traf(tid, flags):
return box("traf", full("tfhd", 0, struct.pack(">I", tid)) + full("trun", 0x4, struct.pack(">II", 1, flags)))
return box("moof", box("mfhd", struct.pack(">II", 0, seq)) + traf(2, 0)
+ traf(1, 0 if video_key else L.SAMPLE_NON_SYNC)) + box("mdat", b"x" * 8)
def test_audio_track_codecs_and_video_track():
init = av_init()
assert L.codec_string(init) == "avc1.42c01f, mp4a.40.2"
assert L.video_track_id(init) == 1
assert L.video_track_id(init_segment()) is None # (the minimal helper has no tkhd/hdlr)
def test_keyframe_looks_at_video_track_only():
assert not L.fragment_is_keyframe(av_fragment(False, 1), 1) # the audio traf's sync sample doesn't count
assert L.fragment_is_keyframe(av_fragment(True, 1), 1)
assert not L.fragment_is_keyframe(av_fragment(True, 1), 7) # no such track
def test_relay_with_audio():
r = L.Relay()
r.feed(av_init() + av_fragment(True, 1) + av_fragment(False, 2) + av_fragment(False, 3))
_, gop, _ = r.subscribe()
assert len(gop) == 3 # the non-key fragments with audio didn't restart the group
st = r.status()
assert st["audio"] and st["codec"].endswith("mp4a.40.2")
def test_source_command_captures_default_output():
cmd = L.source_command()
assert "pactl get-default-sink" in cmd and "$sink.monitor" in cmd and "-c:a aac" in cmd
audio = cmd[cmd.index("audio=(-thread_queue_size"):cmd.index("fi\n", cmd.index("audio=(-thread_queue_size"))]
assert "-ts mono2abs" in cmd and "-use_wallclock_as_timestamps" not in audio # both on pulse's wall clock
# (the hardware path stamps its raw H.264 input with the wall clock; the pulse input never gets that flag)
assert "aresample=async=1" in cmd
# ------------------------------------------------------------------------------------------------ hardware encoder
def _fake_tools(tmp_path, probe_exit=0, fps=32):
"""A headset-view device, a fake fp_venc (probe JSON / copies stdin to stdout) and a fake ffmpeg (logs its args,
reads its stdin to the end)."""
dev = tmp_path / "video99"
dev.mkdir()
(dev / "name").write_text("SteamVR\n")
bindir = tmp_path / "bin"
bindir.mkdir()
venc = bindir / "fp_venc"
venc.write_text("#!/bin/sh\n"
f'echo "$@" >> {tmp_path}/venc.args\n'
'case " $* " in *" --probe "*)\n'
f' [ {probe_exit} = 0 ] || exit {probe_exit}\n'
f' echo \'{{"encoder":"/dev/video23","fps":{fps},"bitrate":3000000}}\'; exit 0;;\n'
"esac\n"
"exec cat\n")
ffmpeg = bindir / "ffmpeg"
ffmpeg.write_text("#!/bin/sh\n"
f'echo "$@" > {tmp_path}/ffmpeg.args\n'
"exec cat > /dev/null\n")
for f in (venc, ffmpeg):
f.chmod(0o755)
env = {"PATH": f"{bindir}:/usr/bin:/bin", "FP_VENC": str(venc)}
return env
def _run_script(tmp_path, env, quality="720p"):
script = L.source_command(quality).replace("/sys/class/video4linux", str(tmp_path))
return subprocess.Popen(["bash", "-c", script], stdin=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, env=env)
@pytest.mark.skipif(not shutil.which("bash"), reason="needs bash")
def test_source_command_hardware_path(tmp_path):
p = _run_script(tmp_path, _fake_tools(tmp_path, fps=32))
p.stdin.write(b"k") # a keyframe request reaches the encoder (here: copied on to ffmpeg)
p.stdin.flush()
time.sleep(0.5)
assert p.poll() is None
p.stdin.close() # the SSH channel closing ends the encoder, then ffmpeg
assert p.wait(timeout=5) == 0
assert b"live: encoder=hardware fps=32" in p.stderr.read()
args = (tmp_path / "ffmpeg.args").read_text()
assert "-f h264 -i pipe:0" in args and "-c:v copy" in args and "libx264" not in args
assert "-use_wallclock_as_timestamps 1" in args and "nobuffer" not in args # read in step with pulse's audio
assert "setts=ts=N*(1/32)/TB" in args # output timestamps on fp_venc's 32 fps grid (no bunched frames)
assert "-itsoffset -0.25 -f h264 -i pipe:0" in args # video not "ahead" of pulse, not held for interleaving
assert "+frag_keyframe" in args # a requested keyframe starts a fragment (where a new viewer begins)
venc_calls = (tmp_path / "venc.args").read_text().splitlines()
assert venc_calls[0].startswith("--probe ") and "--height 720" in venc_calls[0]
assert "--bitrate 5000000 --peak 7500000" in venc_calls[0] # hardware 720p: VBR, 1.5x peak
assert "--fps 32" in venc_calls[1] and "--probe" not in venc_calls[1]
@pytest.mark.skipif(not shutil.which("bash"), reason="needs bash")
def test_source_command_full_quality_has_no_height(tmp_path):
p = _run_script(tmp_path, _fake_tools(tmp_path), quality="full")
p.stdin.close()
p.wait(timeout=5)
assert "--height" not in (tmp_path / "venc.args").read_text()
@pytest.mark.skipif(not shutil.which("bash"), reason="needs bash")
def test_source_command_falls_back_to_x264(tmp_path):
p = _run_script(tmp_path, _fake_tools(tmp_path, probe_exit=4)) # hardware encoder not usable
time.sleep(0.5)
p.stdin.close()
p.wait(timeout=5)
assert b"live: encoder=software fps=30" in p.stderr.read()
args = (tmp_path / "ffmpeg.args").read_text()
assert "libx264" in args and "-f v4l2" in args
assert len((tmp_path / "venc.args").read_text().splitlines()) == 1 # only the probe ran
def test_bitrate_bps():
assert [L.bitrate_bps(r) for _, r in L.QUALITY.values()] == [1_000_000, 1_500_000, 3_000_000, 6_000_000,
10_000_000]
def test_relay_viewer_waits_for_requested_keyframe():
r = L.Relay()
asked = []
r.on_join = lambda: asked.append(1) or True
r.feed(init_segment() + fragment(True, seq=1) + fragment(False, seq=2))
init, gop, q = r.subscribe()
assert asked and init == init_segment() and gop == [] # no stale group: the requested keyframe comes next
r.feed(fragment(False, seq=3)) # still before the keyframe: not for this viewer
r.feed(fragment(True, seq=4) + fragment(False, seq=5))
assert q.get_nowait() == fragment(True, seq=4)
assert q.get_nowait() == fragment(False, seq=5)
def test_relay_without_keyframe_request_sends_gop():
r = L.Relay()
r.on_join = lambda: False # x264 path: no way to ask
r.feed(init_segment() + fragment(True, seq=1) + fragment(False, seq=2))
_, gop, q = r.subscribe()
assert gop == [fragment(True, seq=1), fragment(False, seq=2)]
r.feed(fragment(False, seq=3))
assert q.get_nowait() == fragment(False, seq=3)
class _Chan:
def __init__(self, data: bytes, err: bytes):
self.data, self.err, self.sent = [data, b""], err, b""
def recv(self, n):
return self.data.pop(0) if self.data else b""
def recv_stderr_ready(self):
return bool(self.err)
def recv_stderr(self, n):
out, self.err = self.err[:n], self.err[n:]
return out
def sendall(self, b):
self.sent += b
def test_frame_source_reads_info_and_errors():
src = L.FrameSource(frame=None, quality="720p")
src.chan = _Chan(b"mp4", b"live: encoder=hardware fps=36\nfp_venc: stats frames=1\nffmpeg: broken pipe")
assert not src.request_keyframe() # nothing known yet
assert src.read(10) == b"mp4"
assert src.info == {"encoder": "hardware", "fps": "36"}
assert src.request_keyframe() and src.chan.sent == b"k"
with pytest.raises(RuntimeError, match="broken pipe"): # the last stderr line explains the end
src.read(10)
src = L.FrameSource(frame=None, quality="720p") # encoder stats only go to the log; an encoder error explains
src.chan = _Chan(b"", b"fp_venc: encoder QBUF (OUTPUT) failed, errno 22\nfp_venc: stats frames=9 late=0\n")
with pytest.raises(RuntimeError, match="QBUF"):
src.read(10)
def test_live_stream_status_has_encoder():
live = L.LiveStream(lambda: _Source(b""))
try:
live.source_info = lambda: {"encoder": "hardware", "fps": "32"}
st = live.status()
assert st["encoder"] == "hardware" and st["fps"] == 32
finally:
live.server.server_close()
class _Frame:
home = "/home/steamos"
def __init__(self, remote_sum: str):
self.remote_sum, self.cmds, self.puts = remote_sum, [], []
def run(self, cmd, **kw):
self.cmds.append(cmd)
if cmd.startswith("sha256sum"):
return 0, f"{self.remote_sum} x\n" if self.remote_sum else "", ""
return 0, "", ""
def put(self, local, remote, **kw):
self.puts.append(remote)
def test_ensure_helper_uploads_only_when_different(tmp_path, monkeypatch):
import hashlib
exe = tmp_path / "fp_venc"
exe.write_bytes(b"\x7fELF test")
monkeypatch.setattr(L, "helper_path", lambda: exe)
same = _Frame(hashlib.sha256(exe.read_bytes()).hexdigest())
assert L.ensure_helper(same) and not same.puts
other = _Frame("0" * 64)
assert L.ensure_helper(other)
assert len(other.puts) == 1 and other.puts[0].startswith("/home/steamos/.local/share/frameport/bin/fp_venc.new-")
assert any("chmod 755" in c and "mv -f" in c and other.puts[0] in c for c in other.cmds)
monkeypatch.setattr(L, "helper_path", lambda: None)
assert not L.ensure_helper(_Frame("")) # a build without the helper: software encoder
def test_status_text_names_encoder():
from frameport.ui.views.live import status_text
line = status_text({"ready": True, "width": 1280, "height": 720, "fps": 32, "encoder": "hardware",
"bytes": 1000, "seconds": 1, "viewers": 1})
assert "32 fps" in line and "hardware encoder" in line
def test_window_stats_and_panel_warning():
c, w = L.window_stats("fp_venc: stats frames=320 repeats=5 late=0 skipped=0 kbps=4900", {})
assert w == {"frames": 320, "dropped": 0, "kbps": 4900, "dropping": False}
c, w = L.window_stats("fp_venc: stats frames=528 repeats=140 late=172 skipped=104 kbps=6627", c)
assert w["frames"] == 208 and w["dropped"] == 276 and w["dropping"] # the owner's 1080p run
c, w = L.window_stats("fp_venc: stats frames=846 repeats=140 late=176 skipped=104 kbps=6600", c)
assert w["dropped"] == 4 and not w["dropping"] # a few late slots in 10 s: no warning
from frameport.ui.views.live import health_text
assert "lower quality" in health_text({"dropping": True, "dropped": 276})
assert health_text({"dropping": False, "dropped": 4}) == "" and health_text({}) == ""
class _StopChan:
def __init__(self, exits: bool):
self.exits, self.closed, self.eof = exits, False, False
def shutdown_write(self):
self.eof = True
def exit_status_ready(self):
return self.exits
def recv_ready(self):
return False
def recv_stderr_ready(self):
return False
def close(self):
self.closed = True
def test_stop_kills_a_stuck_stream():
"""fp_venc blocked in a write never sees stdin EOF: after the grace time its process group is ended."""
frame = _Frame("")
src = L.FrameSource(frame, "1080p")
src.chan, src.info = _StopChan(exits=False), {"pgid": "4321"}
src.close(grace=0.1)
assert src.chan.eof and src.chan.closed
assert any("kill -TERM -- -4321" in c for c in frame.cmds)
frame = _Frame("")
src = L.FrameSource(frame, "1080p")
src.chan, src.info = _StopChan(exits=True), {"pgid": "4321"}
src.close(grace=0.1)
assert not any("kill" in c for c in frame.cmds) # stopped by itself: nothing to kill
def test_status_has_dropped_frames():
live = L.LiveStream(lambda: _Source(b""))
try:
live.source_info = lambda: {"encoder": "hardware", "fps": "32", "stats": {"dropped": 9, "dropping": True}}
st = live.status()
assert st["dropped"] == 9 and st["dropping"]
live.source_info = lambda: {"encoder": "software", "fps": "30", "stats": {}}
assert "dropping" not in live.status() # x264 path: no stats
finally:
live.server.server_close()