diff options
Diffstat (limited to 'packages/meshbay-node/tests')
| -rw-r--r-- | packages/meshbay-node/tests/test_stream_hevc_transcode.py | 155 | ||||
| -rw-r--r-- | packages/meshbay-node/tests/test_stream_video_transcode.py | 273 |
2 files changed, 273 insertions, 155 deletions
diff --git a/packages/meshbay-node/tests/test_stream_hevc_transcode.py b/packages/meshbay-node/tests/test_stream_hevc_transcode.py deleted file mode 100644 index 7d831ee..0000000 --- a/packages/meshbay-node/tests/test_stream_hevc_transcode.py +++ /dev/null @@ -1,155 +0,0 @@ -""" -HEVC video is transcoded to H264 for streaming, never copied — unlike the -codecs media_probe.py's BROWSER_INCOMPATIBLE_VIDEO_CODECS excludes. - -Found live: a real HEVC/EAC3 WEB-DL streamed fine over MNP (ffprobe/VLC play -it) but the browser reported "Codec not supported for streaming: -hev1.1.6.L93.B0,mp4a.40.2" from MediaSource.isTypeSupported — Chrome has no -HEVC decoder on most non-Apple platforms. "-c:v copy" on an incompatible -codec is not a mux failure the way EAC3 audio is (test_stream_audio_ -transcode.py); ffmpeg happily remuxes it, and the browser is the one that -then refuses it, silently, at playback rather than at stream_init. - -These tests spawn real ffmpeg/ffprobe against small synthetic files (lavfi -test sources, ~1s), the same style as test_stream_audio_transcode.py. -""" - -import shutil -import subprocess -from pathlib import Path - -import pytest -from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey - -from meshbay_common.crypto import generate_gek -from meshbay_common.webcrypto import chunk_key_aes, decrypt_chunk_aes -from meshbay_node.indexer.group_index import GroupIndex -from meshbay_node.transport.webrtc_server import WebRTCPeerSession, _probe_video - -from conftest import needs_subprocess, one_root - -_HAVE_FFMPEG = shutil.which("ffmpeg") and shutil.which("ffprobe") -_HAVE_HEVC_ENCODER = _HAVE_FFMPEG and b"libx265" in subprocess.run( - ["ffmpeg", "-hide_banner", "-encoders"], capture_output=True).stdout -pytestmark = [ - pytest.mark.asyncio, - pytest.mark.skipif(not _HAVE_HEVC_ENCODER, reason="ffmpeg/libx265 not installed"), - needs_subprocess, -] - - -def _make_hevc_clip(path: Path) -> None: - """~1s of HEVC video + AAC audio — a minimal stand-in for a real HEVC WEB-DL.""" - subprocess.run( - ["ffmpeg", "-hide_banner", "-loglevel", "error", "-y", - "-f", "lavfi", "-i", "testsrc=size=320x240:rate=25:duration=1", - "-f", "lavfi", "-i", "sine=frequency=440:duration=1:sample_rate=48000", - "-c:v", "libx265", "-preset", "ultrafast", "-c:a", "aac", - str(path)], - check=True, capture_output=True, - ) - - -def _session(video_path: Path, gek: bytes, *, transcode_incompatible_video: bool = True): - import blake3 - file_bytes = video_path.read_bytes() - file_id = blake3.blake3(file_bytes).hexdigest() - - sk_node = Ed25519PrivateKey.generate() - index = GroupIndex(group_id="g" * 32, sk_node=sk_node, gek=gek) - from meshbay_common.protocol import IndexEntry - index.add_entry(IndexEntry( - id=file_id, name=video_path.name, path=video_path.parent.name, - size=len(file_bytes), type="video", added_at=0)) - - session = WebRTCPeerSession.__new__(WebRTCPeerSession) - session._ctx = { - "roots": one_root(video_path.parent), - "index": index, - "gek": gek, - "sk_node": sk_node, - "max_concurrent_streams": 4, - "transcode_incompatible_video": transcode_incompatible_video, - } - session._group_id = None - session._user_id = "tester" - session._stream_stopped = False - session._stream_keepalives = 0 - session.sent = [] - session._send = session.sent.append - session._audit = lambda *a, **k: None - return session, file_id - - -def _reassemble(sent: list[dict], gek: bytes, file_id: str) -> bytes: - file_hash = bytes.fromhex(file_id) - segments = sorted( - (m for m in sent if m.get("type") == "stream_data"), - key=lambda m: m["segment_index"]) - out = b"" - for m in segments: - key = chunk_key_aes(gek, file_hash, m["segment_index"]) - out += decrypt_chunk_aes(key, m["nonce"], m["ct"]) - return out - - -def _output_video_codec(path: Path) -> str: - probe = subprocess.run( - ["ffprobe", "-v", "error", "-select_streams", "v:0", - "-show_entries", "stream=codec_name", "-of", "csv=p=0", str(path)], - check=True, capture_output=True, text=True) - return probe.stdout.strip() - - -async def test_hevc_video_is_transcoded_to_h264_by_default(tmp_path): - clip = tmp_path / "clip.mkv" - _make_hevc_clip(clip) - gek = generate_gek() - session, file_id = _session(clip, gek) - - await session._stream_video_inner({"file_id": file_id, "start": 0, "credits": 0}) - - errors = [m for m in session.sent if m.get("type") == "error"] - assert not errors, f"streaming must not fail: {errors}" - - init = next(m for m in session.sent if m.get("type") == "stream_init") - assert init["codec"].startswith("avc1."), ( - "the reported codec must be the transcoded H264 string, never the " - f"source's hev1 string a browser cannot decode: {init['codec']}") - - remuxed = _reassemble(session.sent, gek, file_id) - out_path = tmp_path / "out.mp4" - out_path.write_bytes(remuxed) - assert _output_video_codec(out_path) == "h264", \ - "the bytes on the wire must actually be H264, not just the reported label" - - -async def test_hevc_transcode_can_be_disabled_by_the_operator(tmp_path): - clip = tmp_path / "clip.mkv" - _make_hevc_clip(clip) - gek = generate_gek() - session, file_id = _session(clip, gek, transcode_incompatible_video=False) - - await session._stream_video_inner({"file_id": file_id, "start": 0, "credits": 0}) - - assert not [m for m in session.sent if m.get("type") == "error"] - init = next(m for m in session.sent if m.get("type") == "stream_init") - assert init["codec"].startswith("hev1."), ( - "with the fallback disabled, the source is copied as-is and the " - f"original HEVC codec string must be reported unchanged: {init['codec']}") - - remuxed = _reassemble(session.sent, gek, file_id) - out_path = tmp_path / "out.mp4" - out_path.write_bytes(remuxed) - assert _output_video_codec(out_path) == "hevc", \ - "with the fallback disabled, the wire bytes must still be copied HEVC" - - -async def test_probe_video_reports_raw_codec_name_for_hevc(tmp_path): - clip = tmp_path / "clip.mkv" - _make_hevc_clip(clip) - - codec, duration, has_audio, width, height, raw_codec = await _probe_video(str(clip)) - - assert raw_codec == "hevc" - assert codec is not None and codec.startswith("hev1.") diff --git a/packages/meshbay-node/tests/test_stream_video_transcode.py b/packages/meshbay-node/tests/test_stream_video_transcode.py new file mode 100644 index 0000000..69f4c2d --- /dev/null +++ b/packages/meshbay-node/tests/test_stream_video_transcode.py @@ -0,0 +1,273 @@ +""" +A video the browser cannot decode is re-encoded to H264, never refused and +never copied. There are two kinds of those, and both are here. + +**A codec string that is real but useless.** A real HEVC/EAC3 WEB-DL streamed +fine over MNP (ffprobe/VLC play it) but the browser reported "Codec not +supported for streaming: hev1.1.6.L93.B0,mp4a.40.2" from +MediaSource.isTypeSupported — Chrome has no HEVC decoder on most non-Apple +platforms. "-c:v copy" on such a codec is not a mux failure the way EAC3 audio +is (test_stream_audio_transcode.py); ffmpeg happily remuxes it, and the browser +is the one that then refuses it, silently, at playback rather than at +stream_init. That is what BROWSER_INCOMPATIBLE_VIDEO_CODECS names. + +**No codec string at all.** MPEG-4 Part 2 (Xvid, DivX), MPEG-2, VC-1, WMV and +Theora have no MediaSource decoder anywhere, so `probe_video` maps them to +None — there is nothing to put in `stream_init` for the client to check. Until +2026-09-09 the streaming path read that None as "unsupported" and refused, with +"Unsupported video codec", on files ffmpeg re-encodes in real time. Reported +live against an episode rip in a `.avi` — mpeg4 video, mp3 audio, 720x404 — +which the reporting machine re-encodes at about six times playback speed. The setting +that governs it, `transcode_incompatible_video`, was documented from the start +as covering "HEVC *and other browser-incompatible video codecs*" (draft-v6 +§2.11); only HEVC was ever wired up. + +These tests spawn real ffmpeg/ffprobe against small synthetic files (lavfi +test sources, ~1s), the same style as test_stream_audio_transcode.py. +""" + +import shutil +import subprocess +from pathlib import Path + +import pytest +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + +from meshbay_common.crypto import generate_gek +from meshbay_common.webcrypto import chunk_key_aes, decrypt_chunk_aes +from meshbay_node.indexer.group_index import GroupIndex +from meshbay_node.transport.webrtc_server import WebRTCPeerSession, _probe_video + +from conftest import needs_subprocess, one_root + +_HAVE_FFMPEG = shutil.which("ffmpeg") and shutil.which("ffprobe") +_ENCODERS = subprocess.run(["ffmpeg", "-hide_banner", "-encoders"], + capture_output=True).stdout if _HAVE_FFMPEG else b"" +# libx265 gates the HEVC cases and only those: an ffmpeg without it still +# encodes MPEG-4 Part 2 (a built-in) and can therefore still prove the half of +# this policy that has no codec string. Marking the whole module on it skipped +# the reported defect on any box without x265. +_HAVE_HEVC_ENCODER = b"libx265" in _ENCODERS +_HAVE_MP3_ENCODER = b"libmp3lame" in _ENCODERS +pytestmark = [ + pytest.mark.asyncio, + pytest.mark.skipif(not _HAVE_FFMPEG, reason="ffmpeg/ffprobe not installed"), + needs_subprocess, +] +needs_hevc = pytest.mark.skipif(not _HAVE_HEVC_ENCODER, + reason="ffmpeg built without libx265") +needs_mp3 = pytest.mark.skipif(not _HAVE_MP3_ENCODER, + reason="ffmpeg built without libmp3lame") + + +def _make_mpeg4_clip(path: Path) -> None: + """~1s of Xvid-style MPEG-4 Part 2 video + MP3 audio in an AVI. + + The same shape as the reported file, down to the container: ffprobe reports + `codec_name: mpeg4`, which is what an Xvid encoder produces and what + `probe_video` has no MSE string for. + """ + subprocess.run( + ["ffmpeg", "-hide_banner", "-loglevel", "error", "-y", + "-f", "lavfi", "-i", "testsrc=size=320x240:rate=25:duration=1", + "-f", "lavfi", "-i", "sine=frequency=440:duration=1:sample_rate=48000", + "-c:v", "mpeg4", "-c:a", "libmp3lame", + str(path)], + check=True, capture_output=True, + ) + + +def _make_hevc_clip(path: Path) -> None: + """~1s of HEVC video + AAC audio — a minimal stand-in for a real HEVC WEB-DL.""" + subprocess.run( + ["ffmpeg", "-hide_banner", "-loglevel", "error", "-y", + "-f", "lavfi", "-i", "testsrc=size=320x240:rate=25:duration=1", + "-f", "lavfi", "-i", "sine=frequency=440:duration=1:sample_rate=48000", + "-c:v", "libx265", "-preset", "ultrafast", "-c:a", "aac", + str(path)], + check=True, capture_output=True, + ) + + +def _session(video_path: Path, gek: bytes, *, transcode_incompatible_video: bool = True): + import blake3 + file_bytes = video_path.read_bytes() + file_id = blake3.blake3(file_bytes).hexdigest() + + sk_node = Ed25519PrivateKey.generate() + index = GroupIndex(group_id="g" * 32, sk_node=sk_node, gek=gek) + from meshbay_common.protocol import IndexEntry + index.add_entry(IndexEntry( + id=file_id, name=video_path.name, path=video_path.parent.name, + size=len(file_bytes), type="video", added_at=0)) + + session = WebRTCPeerSession.__new__(WebRTCPeerSession) + session._ctx = { + "roots": one_root(video_path.parent), + "index": index, + "gek": gek, + "sk_node": sk_node, + "max_concurrent_streams": 4, + "transcode_incompatible_video": transcode_incompatible_video, + } + session._group_id = None + session._user_id = "tester" + session._stream_stopped = False + session._stream_keepalives = 0 + session.sent = [] + session._send = session.sent.append + session._audit = lambda *a, **k: None + return session, file_id + + +def _reassemble(sent: list[dict], gek: bytes, file_id: str) -> bytes: + file_hash = bytes.fromhex(file_id) + segments = sorted( + (m for m in sent if m.get("type") == "stream_data"), + key=lambda m: m["segment_index"]) + out = b"" + for m in segments: + key = chunk_key_aes(gek, file_hash, m["segment_index"]) + out += decrypt_chunk_aes(key, m["nonce"], m["ct"]) + return out + + +def _output_video_codec(path: Path) -> str: + probe = subprocess.run( + ["ffprobe", "-v", "error", "-select_streams", "v:0", + "-show_entries", "stream=codec_name", "-of", "csv=p=0", str(path)], + check=True, capture_output=True, text=True) + return probe.stdout.strip() + + +@needs_hevc +async def test_hevc_video_is_transcoded_to_h264_by_default(tmp_path): + clip = tmp_path / "clip.mkv" + _make_hevc_clip(clip) + gek = generate_gek() + session, file_id = _session(clip, gek) + + await session._stream_video_inner({"file_id": file_id, "start": 0, "credits": 0}) + + errors = [m for m in session.sent if m.get("type") == "error"] + assert not errors, f"streaming must not fail: {errors}" + + init = next(m for m in session.sent if m.get("type") == "stream_init") + assert init["codec"].startswith("avc1."), ( + "the reported codec must be the transcoded H264 string, never the " + f"source's hev1 string a browser cannot decode: {init['codec']}") + + remuxed = _reassemble(session.sent, gek, file_id) + out_path = tmp_path / "out.mp4" + out_path.write_bytes(remuxed) + assert _output_video_codec(out_path) == "h264", \ + "the bytes on the wire must actually be H264, not just the reported label" + + +@needs_hevc +async def test_hevc_transcode_can_be_disabled_by_the_operator(tmp_path): + clip = tmp_path / "clip.mkv" + _make_hevc_clip(clip) + gek = generate_gek() + session, file_id = _session(clip, gek, transcode_incompatible_video=False) + + await session._stream_video_inner({"file_id": file_id, "start": 0, "credits": 0}) + + assert not [m for m in session.sent if m.get("type") == "error"] + init = next(m for m in session.sent if m.get("type") == "stream_init") + assert init["codec"].startswith("hev1."), ( + "with the fallback disabled, the source is copied as-is and the " + f"original HEVC codec string must be reported unchanged: {init['codec']}") + + remuxed = _reassemble(session.sent, gek, file_id) + out_path = tmp_path / "out.mp4" + out_path.write_bytes(remuxed) + assert _output_video_codec(out_path) == "hevc", \ + "with the fallback disabled, the wire bytes must still be copied HEVC" + + +@needs_hevc +async def test_probe_video_reports_raw_codec_name_for_hevc(tmp_path): + clip = tmp_path / "clip.mkv" + _make_hevc_clip(clip) + + codec, duration, has_audio, width, height, raw_codec = await _probe_video(str(clip)) + + assert raw_codec == "hevc" + assert codec is not None and codec.startswith("hev1.") + + +@needs_mp3 +async def test_probe_video_reports_no_codec_string_for_mpeg4(tmp_path): + """The other shape, and the reason the streaming path has to read it as + "re-encode this" rather than as a verdict on the file: there is no MSE + string for MPEG-4 Part 2 because no browser has a decoder to give one to. + The raw name is still reported, which is what the caller decides on.""" + clip = tmp_path / "clip.avi" + _make_mpeg4_clip(clip) + + codec, duration, has_audio, width, height, raw_codec = await _probe_video(str(clip)) + + assert raw_codec == "mpeg4" + assert codec is None, ( + "a codec string for MPEG-4 Part 2 would be one no browser can act on") + assert has_audio is True + assert (width, height) == (320, 240) + + +@needs_mp3 +async def test_a_codec_with_no_mse_string_is_transcoded_not_refused(tmp_path): + """The reported defect. + + An Xvid/MP3 .avi answered "Unsupported video codec" — a refusal, on a file + ffmpeg re-encodes faster than it plays. Nothing about the source changed; + the node simply never reached its own re-encode path. + """ + clip = tmp_path / "clip.avi" + _make_mpeg4_clip(clip) + gek = generate_gek() + session, file_id = _session(clip, gek) + + await session._stream_video_inner({"file_id": file_id, "start": 0, "credits": 0}) + + errors = [m for m in session.sent if m.get("type") == "error"] + assert not errors, ( + f"a source with no MSE codec string was refused instead of re-encoded: " + f"{errors}") + + init = next(m for m in session.sent if m.get("type") == "stream_init") + assert init["codec"].startswith("avc1."), ( + "stream_init must advertise the H264 the client is about to receive; " + f"there is no string for the source and none may be invented: {init['codec']}") + + remuxed = _reassemble(session.sent, gek, file_id) + out_path = tmp_path / "out.mp4" + out_path.write_bytes(remuxed) + assert _output_video_codec(out_path) == "h264", ( + "the bytes on the wire must actually be H264, not just the label") + + +@needs_mp3 +async def test_the_operator_can_refuse_the_re_encode_and_says_so(tmp_path): + """`transcode_incompatible_video = false` still means what it says. + + HEVC falls back to a copy there (the case above), because there is a codec + string to report. Here there is none, so a copy is not a lesser answer — + it is a stream_init the client refuses before the first byte. The refusal + has to name the setting: "Unsupported video codec" is what sent the last + reader to look at a file that was fine. + """ + clip = tmp_path / "clip.avi" + _make_mpeg4_clip(clip) + gek = generate_gek() + session, file_id = _session(clip, gek, transcode_incompatible_video=False) + + await session._stream_video_inner({"file_id": file_id, "start": 0, "credits": 0}) + + errors = [m for m in session.sent if m.get("type") == "error"] + assert errors, "a stream that cannot be produced must be refused, not started" + detail = errors[0]["detail"] + assert "transcoding" in detail, ( + f"the refusal must point at the setting that caused it: {detail!r}") + assert not [m for m in session.sent if m.get("type") == "stream_init"], ( + "a stream_init went out with no codec string the client could check") |