diff options
Diffstat (limited to 'packages/meshbay-node')
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py | 86 | ||||
| -rw-r--r-- | packages/meshbay-node/tests/test_stream_audio_transcode.py | 174 |
2 files changed, 238 insertions, 22 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py index fa6c3e9..aaf1ecf 100644 --- a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py +++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py @@ -166,8 +166,21 @@ STREAM_CREDIT_POLL = 3 _H264_PROFILES = {"Baseline": "42", "Main": "4d", "High": "64", "High 10": "6e"} -async def _probe_video(path: str) -> tuple[str | None, float]: - """Probe video file with ffprobe, return (MSE codec string, duration).""" +async def _probe_video(path: str) -> tuple[str | None, float, bool]: + """ + Probe video file with ffprobe, return (MSE codec string, duration, + has_audio). + + The audio half of the codec string is always "mp4a.40.2" (AAC-LC) or + absent — never the source's real audio codec — because _stream_video_ + inner always transcodes audio to AAC and never copies it: MSE in every + mainstream browser only decodes AAC/Opus, and a source codec outside + that (AC-3, E-AC-3, DTS, ...) is at best silently unplayable and at + worst, for E-AC-3 at least, makes ffmpeg itself refuse to write the + fragmented MP4 header ("Cannot write moov atom before EAC3 packets + parsed" — reproduced against a real 5.1 E-AC-3 WEB-DL). Video stays + whatever it actually is: it is always copied, never transcoded. + """ import json as _json proc = await asyncio.create_subprocess_exec( "ffprobe", "-v", "error", @@ -180,7 +193,8 @@ async def _probe_video(path: str) -> tuple[str | None, float]: info = _json.loads(stdout) duration = float(info.get("format", {}).get("duration", 0)) - v_codec = a_codec = "" + v_codec = "" + has_audio = False for s in info.get("streams", []): if s.get("codec_type") == "video" and not v_codec: cn = s.get("codec_name", "") @@ -194,23 +208,13 @@ async def _probe_video(path: str) -> tuple[str | None, float]: v_codec = "vp09.00.10.08" elif cn == "av1": v_codec = "av01.0.01M.08" - elif s.get("codec_type") == "audio" and not a_codec: - cn = s.get("codec_name", "") - if cn == "aac": - a_codec = "mp4a.40.2" - elif cn in ("mp3", "mp2"): - a_codec = "mp4a.6b" - elif cn == "opus": - a_codec = "opus" - elif cn == "ac3": - a_codec = "ac-3" - elif cn == "flac": - a_codec = "flac" + elif s.get("codec_type") == "audio": + has_audio = True if not v_codec: - return None, duration - codec = f"{v_codec},{a_codec}" if a_codec else v_codec - return codec, duration + return None, duration, has_audio + codec = f"{v_codec},mp4a.40.2" if has_audio else v_codec + return codec, duration, has_audio def _pack(obj: dict) -> bytes: @@ -3005,7 +3009,7 @@ class WebRTCPeerSession: file_hash = bytes.fromhex(entry.id) try: - codec_str, duration = await _probe_video(str(file_path)) + codec_str, duration, has_audio = await _probe_video(str(file_path)) except Exception as e: self._send({"type": "error", "detail": f"Probe failed: {e}"}) return @@ -3034,11 +3038,31 @@ class WebRTCPeerSession: # what every streaming player does, and why the client is told the # value used rather than left to assume its own. seek_args = ["-ss", f"{start:.3f}"] if start > 0 else [] + # Video is always copied — re-encoding it is the expensive thing this + # pipeline exists to avoid, and H264/HEVC/VP9/AV1 already decode fine + # in-browser. Audio is always transcoded to AAC, never copied — see + # _probe_video for why "copy" there is not an option, not even for a + # codec that sounds close enough (plain AC-3 has the same in-browser + # decode problem as E-AC-3, just without ffmpeg also refusing to mux + # it). Transcoding audio is cheap; it does not change the cost model + # the transcode-slot semaphore is sized around. + map_args = ["-map", "0:v:0"] + codec_args = ["-c:v", "copy"] + if has_audio: + map_args += ["-map", "0:a:0"] + # Downmixed to stereo: a WEB-DL's 5.1 track becomes 6-channel AAC + # with no "-ac", which ffprobe and VLC accept fine but which some + # browsers' MSE decoder rejects outright once real fragments are + # appended — isTypeSupported() only checks the codec string, so + # the failure doesn't surface until playback, as a SourceBuffer + # forced out of its MediaSource with no further explanation. + codec_args += ["-c:a", "aac", "-ac", "2", "-b:a", "192k"] proc = await asyncio.create_subprocess_exec( "ffmpeg", "-hide_banner", "-loglevel", "error", *seek_args, "-i", str(file_path), - "-c", "copy", + *map_args, + *codec_args, "-movflags", "frag_keyframe+empty_moov+default_base_moof", "-f", "mp4", "pipe:1", stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, @@ -3125,11 +3149,14 @@ class WebRTCPeerSession: # Drain first, then wait with a bound. The slot must come back even # if the process is being stubborn: it has already had SIGKILL, and # the OS will reap it whether or not we are still watching. + stderr_output = b"" for pipe in (proc.stdout, proc.stderr): if pipe is None: continue try: - await asyncio.wait_for(pipe.read(), timeout=2) + drained = await asyncio.wait_for(pipe.read(), timeout=2) + if pipe is proc.stderr: + stderr_output = drained except Exception: pass try: @@ -3138,7 +3165,22 @@ class WebRTCPeerSession: log.warning("stream: ffmpeg did not reap in 5s — " "releasing the slot regardless") - if not self._stream_stopped: + # A positive returncode is ffmpeg exiting on its own with an error, + # before we ever killed it (a kill shows up as a negative signal + # number instead) — zero segments in that case is a real failure, + # not a normal end, and saying nothing here is indistinguishable + # from "the file is just this short". Found live against a real + # 5.1 E-AC-3 WEB-DL that ffmpeg refused to even start muxing. + # Detail stays server-side (L3: never hand a peer raw stderr). + if index == 0 and proc.returncode is not None and proc.returncode > 0: + log.error("stream: ffmpeg exited rc=%s before any output — %s", + proc.returncode, + stderr_output.decode(errors="replace").strip().splitlines()[-1:] + or "(no stderr)") + if not self._stream_stopped: + self._send({"type": "error", + "detail": "Could not stream this file"}) + elif not self._stream_stopped: self._send({ "type": MNP.STREAM_END, "v": MNP_VERSION, diff --git a/packages/meshbay-node/tests/test_stream_audio_transcode.py b/packages/meshbay-node/tests/test_stream_audio_transcode.py new file mode 100644 index 0000000..5b1fc46 --- /dev/null +++ b/packages/meshbay-node/tests/test_stream_audio_transcode.py @@ -0,0 +1,174 @@ +""" +Audio in a streamed video is always transcoded to AAC, never copied. + +Found live against a real 5.1 E-AC-3 WEB-DL (season 2 of a show whose season 1 +was AAC and played fine): "-c copy" on an E-AC-3 track makes ffmpeg itself +refuse to write the fragmented MP4 header at all — "Cannot write moov atom +before EAC3 packets parsed" — and even for the codecs that don't make ffmpeg +outright refuse (plain AC-3, DTS, ...), no mainstream browser's MSE decodes +them, so the viewer would see a stream that starts and immediately ends with +no picture and no error. Video is untouched here — it is always copied, +still H264 in every fixture below, since re-encoding it is the expensive +thing this pipeline exists to avoid. + +These tests spawn real ffmpeg/ffprobe against small synthetic files (lavfi +test sources, ~1s) rather than asserting against the source text — a source +match cannot tell a working remux from one ffmpeg silently refuses to write. +""" + +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 one_root + +_HAVE_FFMPEG = shutil.which("ffmpeg") and shutil.which("ffprobe") +pytestmark = [ + pytest.mark.asyncio, + pytest.mark.skipif(not _HAVE_FFMPEG, reason="ffmpeg/ffprobe not installed"), +] + + +def _make_clip(path: Path, *, acodec: str, channels: int = 2) -> None: + """~1s of H264 video + `acodec` audio, muxed into an .mkv — a minimal + stand-in for a real WEB-DL with that audio codec.""" + subprocess.run( + ["ffmpeg", "-hide_banner", "-loglevel", "error", "-y", + "-f", "lavfi", "-i", "testsrc=size=320x240:rate=25:duration=1", + "-f", "lavfi", "-i", f"sine=frequency=440:duration=1:sample_rate=48000", + "-ac", str(channels), + "-c:v", "libx264", "-preset", "ultrafast", "-c:a", acodec, + str(path)], + check=True, capture_output=True, + ) + + +def _session(tmp_path: Path, video_path: Path, gek: bytes) -> WebRTCPeerSession: + 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, + } + 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 + + +async def test_eac3_audio_is_transcoded_not_copied(tmp_path): + """The exact reproduction: ffmpeg refuses "-c copy" on E-AC-3 outright.""" + clip = tmp_path / "clip.mkv" + _make_clip(clip, acodec="eac3", channels=6) + gek = generate_gek() + session, file_id = _session(tmp_path, 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 "mp4a.40.2" in init["codec"], \ + "the reported codec must be AAC, never the source's eac3" + + data_segments = [m for m in session.sent if m.get("type") == "stream_data"] + assert data_segments, "no video data was ever sent" + assert any(m.get("type") == "stream_end" for m in session.sent) + + remuxed = _reassemble(session.sent, gek, file_id) + out_path = tmp_path / "out.mp4" + out_path.write_bytes(remuxed) + probe = subprocess.run( + ["ffprobe", "-v", "error", "-show_entries", "stream=codec_name,codec_type,channels", + "-of", "csv=p=0", str(out_path)], + check=True, capture_output=True, text=True) + rows = [line.split(",") for line in probe.stdout.strip().splitlines()] + codecs = {r[1]: r[0] for r in rows} + channels = {r[1]: r[2] for r in rows if len(r) > 2} + assert codecs.get("audio") == "aac", f"audio must be AAC on the wire: {codecs}" + assert codecs.get("video") == "h264", f"video must still be H264: {codecs}" + assert channels.get("audio") == "2", ( + "audio must be downmixed to stereo: some browsers' MSE decoder " + f"silently rejects multichannel AAC once real fragments are " + f"appended, even though ffprobe/VLC accept it fine: {channels}") + + +async def test_already_aac_audio_still_streams(tmp_path): + """Season-1-shaped file (already AAC): must keep working exactly as before.""" + clip = tmp_path / "clip.mkv" + _make_clip(clip, acodec="aac", channels=2) + gek = generate_gek() + session, file_id = _session(tmp_path, clip, gek) + + 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"] + assert any(m.get("type") == "stream_data" for m in session.sent) + assert any(m.get("type") == "stream_end" for m in session.sent) + + +async def test_probe_video_reports_aac_regardless_of_source_audio_codec(tmp_path): + clip = tmp_path / "clip.mkv" + _make_clip(clip, acodec="eac3", channels=6) + + codec, duration, has_audio = await _probe_video(str(clip)) + + assert has_audio is True + assert duration > 0 + assert codec is not None + assert "eac3" not in codec and "ec-3" not in codec + assert "mp4a.40.2" in codec + + +async def test_probe_video_handles_no_audio_track(tmp_path): + clip = tmp_path / "silent.mkv" + subprocess.run( + ["ffmpeg", "-hide_banner", "-loglevel", "error", "-y", + "-f", "lavfi", "-i", "testsrc=size=320x240:rate=25:duration=1", + "-c:v", "libx264", "-preset", "ultrafast", "-an", str(clip)], + check=True, capture_output=True, + ) + + codec, duration, has_audio = await _probe_video(str(clip)) + + assert has_audio is False + assert codec is not None and "," not in codec, \ + "no audio track must not produce a dangling ',' or a fake audio codec" |