aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/src/meshbay_node/media_probe.py
blob: 06b1e28f06031c5e347ea00a032edc3acbffe3d3 (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
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
"""
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


# Subtitle codecs ffmpeg can convert to WebVTT, which is the only thing MSE
# can be given. An allow-list rather than a bitmap deny-list: the cost of
# wrongly excluding an exotic text codec is a track nobody can pick, and the
# cost of wrongly including a bitmap one is a track that is picked and then
# displays nothing, with no error to lead anyone back here.
TEXT_SUBTITLE_CODECS = frozenset({
    "subrip", "srt", "ass", "ssa", "mov_text", "webvtt", "text",
    "subviewer", "subviewer1", "sami", "realtext", "stl", "jacosub",
    "microdvd", "mpl2", "vplayer", "pjs",
})


@dataclass(frozen=True)
class SubtitleTrack:
    """
    One selectable subtitle track, guaranteed convertible to WebVTT.

    **`ordinal` counts every subtitle stream, including the bitmap ones this
    list does not carry**, because that is what `-map 0:s:<n>` counts. The
    same trap as `AudioTrack.ordinal` one level deeper: filtering the list and
    numbering the survivors would give a file whose streams are PGS, SRT, SRT
    the ordinals 0 and 1 for its two text tracks, and `-map 0:s:0` would then
    extract the PGS stream — which produces an empty WebVTT rather than an
    error, so the viewer gets a subtitle track with no subtitles in it.
    """
    ordinal: int
    language: str | None
    title: str | None
    codec_name: str | 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)
    subtitle_tracks: list[SubtitleTrack] = 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)`.

    **Only text subtitle tracks are reported.** A library's embedded subtitles
    are roughly four-fifths text (subrip, ass) and one-fifth bitmap (PGS,
    VOBSUB); a bitmap track has no path to WebVTT without OCR, so listing one
    would offer a choice that silently displays nothing. A file whose only
    subtitles are bitmap therefore reports none at all and gets no selector,
    exactly like a file with no subtitles — which is a true statement about
    what this node can serve, not a concealed failure.

    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] = []
    subtitle_tracks: list[SubtitleTrack] = []
    subtitle_streams_seen = 0
    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"),
            ))
        elif s.get("codec_type") == "subtitle":
            # Counted before the filter, never after — see SubtitleTrack.
            ordinal = subtitle_streams_seen
            subtitle_streams_seen += 1
            codec_name = (s.get("codec_name") or "").strip() or None
            if codec_name not in TEXT_SUBTITLE_CODECS:
                continue
            tags = s.get("tags") or {}
            subtitle_tracks.append(SubtitleTrack(
                ordinal=ordinal,
                language=(tags.get("language") or "").strip() or None,
                title=(tags.get("title") or "").strip() or None,
                codec_name=codec_name,
            ))

    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,
        subtitle_tracks=subtitle_tracks,
    )