aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/src/meshbay_node/media_probe.py
blob: 7858ebeadec3929b2b6746471d94af0fd8d4353c (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
"""
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
from dataclasses import dataclass, field

_H264_PROFILES = {"Baseline": "42", "Main": "4d", "High": "64", "High 10": "6e"}

# Source video codecs whose MSE codec string is real but which no mainstream
# browser can actually decode via MediaSource on most desktop platforms — HEVC
# has no royalty-free decoder in Chrome/Firefox on Linux (and is spotty even
# on platforms with one). Found live: a real HEVC/EAC3 WEB-DL reported
# "Codec not supported for streaming: hev1.1.6.L93.B0,mp4a.40.2" from
# MediaSource.isTypeSupported, even though ffprobe/VLC play it fine. VP9/AV1
# are not in this set — those decode natively in every mainstream browser.
BROWSER_INCOMPATIBLE_VIDEO_CODECS = frozenset({"hevc"})


@dataclass(frozen=True)
class AudioTrack:
    """
    One selectable audio track.

    **`ordinal` is the position among the audio streams, not the container
    stream index**, because that is what `-map 0:a:<n>` takes. A file whose
    audio sits at container indices 1, 2 and 3 has ordinals 0, 1 and 2, and
    mapping `0:a:1` on the container index would silently serve the third
    track — the failure this field's name exists to prevent.
    """
    ordinal: int
    language: str | None
    title: str | None
    codec_name: str | None
    channels: int | None


@dataclass
class VideoProbe:
    """
    What one ffprobe call says about a video file.

    A dataclass rather than the tuple this used to return: the tuple had six
    positional fields, `has_audio` sat third and `raw_codec_name` sixth, and
    adding a seventh for the track list would have made every call site a
    counting exercise.
    """
    codec: str | None
    duration: float
    has_audio: bool
    width: int | None
    height: int | None
    raw_codec_name: str | None
    audio_tracks: list[AudioTrack] = field(default_factory=list)


async def probe_video(path: str) -> VideoProbe:
    """
    Probe a video file with ffprobe.

    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 is copied
    whenever the browser can decode it directly; the raw codec name is
    returned alongside the MSE string so the caller can decide whether this
    source needs a real re-encode instead (BROWSER_INCOMPATIBLE_VIDEO_CODECS
    above) — the MSE string alone can't drive that decision, since it still
    faithfully reports "hev1..." for a source this pipeline cannot actually
    deliver copied.

    **A `None` codec string means "this must be re-encoded", not "this cannot
    be played".** Only the four codecs a browser can decode through MediaSource
    are mapped; everything else — MPEG-4 Part 2 (Xvid, DivX), MPEG-2, VC-1,
    WMV, Theora — has no string to report because there is no browser decoder
    to report it to, and the streaming path answers that by re-encoding to
    H264. It answered it by refusing until 2026-09-09, which read to the
    operator as a broken file rather than as an unwired code path.

    **Every audio track is reported, not just the first.** The streaming path
    transcodes audio unconditionally, so serving the second track costs exactly
    what serving the first costs and the choice is the viewer's to make; a
    library of dubbed films is one where the first track is a language half the
    group does not want. `has_audio` stays as the single question the muxing
    decisions ask, and is now `bool(audio_tracks)`.

    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).
    """
    from meshbay_node.platform import ffprobe_cmd
    proc = await asyncio.create_subprocess_exec(
        ffprobe_cmd(), "-v", "error",
        "-show_entries",
        "stream=codec_name,profile,level,codec_type,width,height,channels",
        "-show_entries", "stream_tags=language,title",
        "-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 = ""
    raw_codec_name: str | None = None
    width: int | None = None
    height: int | None = None
    audio_tracks: list[AudioTrack] = []
    for s in info.get("streams", []):
        if s.get("codec_type") == "video" and not v_codec:
            cn = s.get("codec_name", "")
            raw_codec_name = cn or None
            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":
            tags = s.get("tags") or {}
            audio_tracks.append(AudioTrack(
                # Counted here, never read from `s["index"]` — see AudioTrack.
                ordinal=len(audio_tracks),
                language=(tags.get("language") or "").strip() or None,
                title=(tags.get("title") or "").strip() or None,
                codec_name=s.get("codec_name") or None,
                channels=s.get("channels"),
            ))

    has_audio = bool(audio_tracks)
    codec = None
    if v_codec:
        codec = f"{v_codec},mp4a.40.2" if has_audio else v_codec
    return VideoProbe(
        codec=codec,
        duration=duration,
        has_audio=has_audio,
        width=width,
        height=height,
        raw_codec_name=raw_codec_name,
        audio_tracks=audio_tracks,
    )