aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/tests/test_stream_video_transcode.py
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-09 17:16:29 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-09 17:16:29 +0200
commit0b9b85bf2d434177968dfc6e27555d54d703ff7e (patch)
tree7a8bf615f3e60f4bb68d1175c2e1acfce5a18d69 /packages/meshbay-node/tests/test_stream_video_transcode.py
parent4d5a07299c792de1251622a41ce7f94870b363fa (diff)
downloadmeshbay-0b9b85bf2d434177968dfc6e27555d54d703ff7e.tar.gz
fix(node): a video the browser cannot decode is re-encoded, not refused
Streaming an Xvid/MP3 .avi answered "Unsupported video codec" — a refusal, on a file ffmpeg re-encodes at about six times playback speed on the machine that reported it. Nothing about the source was wrong. The node simply never reached its own re-encode path. `probe_video` maps a source codec to an MSE codec string and knows four: h264, hevc, vp9, av1. Everything else returns None, because there is no MediaSource decoder in any mainstream browser to give a string to — MPEG-4 Part 2 (Xvid, DivX), MPEG-2, VC-1, WMV, Theora. `_stream_video_inner` read that None as a verdict on the file and refused, while the re-encode sitting twenty lines below it was gated on `raw_video_codec in BROWSER_INCOMPATIBLE_VIDEO_CODECS` — a set containing "hevc" and nothing else. So the whole ffmpeg fallback existed, worked, and was unreachable for every codec that most needed it. The setting that governs the fallback has documented the intended behaviour since it was introduced: draft-v6 §2.11 says `transcode_incompatible_video` covers "HEVC *and other browser-incompatible video codecs*". Only HEVC was ever wired up. Two questions were being answered by one value, and they are separated now. "Is there a video stream at all" is the only thing this path genuinely cannot serve, and the only refusal left. "Can it be copied" needs both an MSE string to put in `stream_init` and a codec browsers decode; a source failing either is re-encoded. The operator's opt-out keeps meaning what it says, and it no longer means the same thing for every source, because it cannot: HEVC has a codec string, so `transcode_incompatible_video = false` falls back to a copy and the viewer's own decoder decides (unchanged). MPEG-4 Part 2 has none, so there is nothing to fall back to — a `stream_init` with no codec string is one the client refuses before the first byte — and the stream is refused naming the setting. "Unsupported video codec" is what sent this report to the file, and the file was fine. Verified against the reported file end to end: ffprobe reports mpeg4/mp3 720x404, the decision comes out `can_copy=False`, and the pipeline's exact argv produces H264 High level 4.1 plus stereo AAC-LC — matching the `avc1.640029,mp4a.40.2` that `stream_init` advertises and that the client puts through MediaSource.isTypeSupported byte for byte. test_stream_hevc_transcode.py becomes test_stream_video_transcode.py: it was always about the policy rather than about one codec, and it now carries both halves of it, with a synthetic Xvid/MP3 .avi built the same way as the HEVC clip. Its module-level skip on libx265 went with it — an ffmpeg without x265 still encodes MPEG-4 Part 2, so that marker was skipping the reported defect entirely on any box without it; it now gates the HEVC cases alone. Three cases added, checked against the unfixed source. Hub and node suites 2269 passed, 4 skipped. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019GXmScYB1uR29YCt74si9J
Diffstat (limited to 'packages/meshbay-node/tests/test_stream_video_transcode.py')
-rw-r--r--packages/meshbay-node/tests/test_stream_video_transcode.py273
1 files changed, 273 insertions, 0 deletions
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")