diff options
Diffstat (limited to 'packages/meshbay-node/src/meshbay_node/transport')
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py | 86 |
1 files changed, 64 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, |