summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/src/meshbay_node
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-node/src/meshbay_node')
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py86
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,