diff options
Diffstat (limited to 'packages/meshbay-node/src/meshbay_node/media_probe.py')
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/media_probe.py | 70 |
1 files changed, 70 insertions, 0 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/media_probe.py b/packages/meshbay-node/src/meshbay_node/media_probe.py new file mode 100644 index 0000000..8ad883d --- /dev/null +++ b/packages/meshbay-node/src/meshbay_node/media_probe.py @@ -0,0 +1,70 @@ +""" +ffprobe wrapper shared by stream-time codec detection (transport/webrtc_server.py) +and index-time technical-field enrichment (indexer/enrich.py). + +Split out of webrtc_server.py so the indexer package (which webrtc_server.py +already imports from) can call it too without a circular import. +""" + +import asyncio +import json + +_H264_PROFILES = {"Baseline": "42", "Main": "4d", "High": "64", "High 10": "6e"} + + +async def probe_video(path: str) -> tuple[str | None, float, bool, int | None, int | None]: + """ + Probe video file with ffprobe, return (MSE codec string, duration, + has_audio, width, height). + + The audio half of the codec string is always "mp4a.40.2" (AAC-LC) or + absent — never the source's real audio codec — because the streaming + path 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. + + width/height come from the same ffprobe call (one extra `-show_entries` + field, no second process spawn) — resolution is deliberately never + guessed from the filename (docs/mediacenter.md §3.5). + """ + proc = await asyncio.create_subprocess_exec( + "ffprobe", "-v", "error", + "-show_entries", "stream=codec_name,profile,level,codec_type,width,height", + "-show_entries", "format=duration", + "-of", "json", path, + stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, + ) + stdout, _ = await proc.communicate() + info = json.loads(stdout) + duration = float(info.get("format", {}).get("duration", 0)) + + v_codec = "" + has_audio = False + width: int | None = None + height: int | None = None + for s in info.get("streams", []): + if s.get("codec_type") == "video" and not v_codec: + cn = s.get("codec_name", "") + if cn == "h264": + p = _H264_PROFILES.get(s.get("profile", "High"), "64") + lvl = int(s.get("level", 40)) + v_codec = f"avc1.{p}00{lvl:02x}" + elif cn == "hevc": + v_codec = "hev1.1.6.L93.B0" + elif cn == "vp9": + v_codec = "vp09.00.10.08" + elif cn == "av1": + v_codec = "av01.0.01M.08" + width = s.get("width") + height = s.get("height") + elif s.get("codec_type") == "audio": + has_audio = True + + if not v_codec: + return None, duration, has_audio, width, height + codec = f"{v_codec},mp4a.40.2" if has_audio else v_codec + return codec, duration, has_audio, width, height |