aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-23 22:43:35 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-23 22:43:35 +0200
commitb6e2dcea65124673da047f9b3c92bc5e25980d63 (patch)
tree2776598c32b30229dcd7d6a3d030bb4965ad1c70 /packages/meshbay-node
parentb3709ac4d362987a9d025616c95065ceed0d216b (diff)
downloadmeshbay-b6e2dcea65124673da047f9b3c92bc5e25980d63.tar.gz
fix(node,hub): always transcode video audio to stereo AAC, never copy
MSE only decodes AAC/Opus, so copying a source's real audio codec left non-AAC files silently unplayable in-browser (E-AC-3 additionally made ffmpeg itself refuse to write the fragmented MP4 header). Audio is now always transcoded to AAC and downmixed to stereo — multichannel AAC is accepted by ffprobe/VLC but silently rejected by some browsers' MSE decoder once real fragments are appended, which forces the SourceBuffer out of its MediaSource with no explicit error. Video stays copy-only. Also: report a clear client-side error instead of a bare STREAM_END when ffmpeg exits nonzero before producing any output, add video-element/ MediaSource error logging on the client for the next time this class of bug needs diagnosing, and fix a hub test that had grown too broad a scan window after an earlier, unrelated transport.js change. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016SF6RKNBKg9qejmoMJ9ybA
Diffstat (limited to 'packages/meshbay-node')
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py86
-rw-r--r--packages/meshbay-node/tests/test_stream_audio_transcode.py174
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"