diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-17 13:39:23 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-17 13:39:23 +0200 |
| commit | ad4ca3229002997934ccb5b2eaeb553c13b8888f (patch) | |
| tree | a680f9e5d3ba43a84236b84f73e0b71eaf916db4 /packages/meshbay-node/src/meshbay_node/media_probe.py | |
| parent | 3e6d514663a5df1be3b2f0286c5f67f669d9c1d6 (diff) | |
| download | meshbay-ad4ca3229002997934ccb5b2eaeb553c13b8888f.tar.gz | |
feat: embedded subtitles in the video player (MNP 3.3)
MSE decodes no in-band text track, so a subtitle cannot ride inside the
fragmented MP4 the player is fed. The node extracts one track whole, converts
it to WebVTT and caches it under its own hash; the client pulls that blob
through the ordinary file_req/chunk path and hangs a <track> on the video
element — the same indirection as a TMDB poster or an audio transcode, which
is what makes a film's subtitles extracted once in the life of the file rather
than once per viewing. Whole-file also makes the cues absolute, so a seek and
an audio-language change both leave the track untouched.
**The ordinal counts every subtitle stream, including the ones never listed.**
Only text codecs are offered: a bitmap track (PGS, VOBSUB — about a fifth of a
real library) has no path to WebVTT without OCR, and one extracted anyway
yields a header with no cues, which is a menu entry that shows nothing and
reports no error. Numbering the survivors of that filter would give a
PGS/SRT/SRT file the ordinals 0 and 1 for its text tracks and `-map 0:s:0`
would then extract the PGS — the same trap `AudioTrack.ordinal` exists for,
one level deeper. A fixture whose first subtitle stream cannot be decoded pins
it, and the handler checks membership of the probed list, never a range.
Additive and MINOR: the selector is drawn from `subtitle_tracks` in the node's
own `stream_init` and from no version number, so `subtitle_req` is never sent
to a peer that would not answer it. The floor stays at 3.0.
Also here: a failed extraction never touches playback, a superseded reply
cannot install its blob over a newer choice, and `_languageName` is shared
with the audio labels — lifted by both label harnesses, since a lift that
names one function stops covering the rule the moment logic moves out of it.
Tests: 9 node (tracks told apart by the words in the extracted cues, not by
tags), 10 client. Full suite green: 1545 node/common, 1252 hub.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UGY17EPph5LsLzePPXhUVc
Diffstat (limited to 'packages/meshbay-node/src/meshbay_node/media_probe.py')
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/media_probe.py | 57 |
1 files changed, 57 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 index 7858ebe..06b1e28 100644 --- a/packages/meshbay-node/src/meshbay_node/media_probe.py +++ b/packages/meshbay-node/src/meshbay_node/media_probe.py @@ -40,6 +40,37 @@ class AudioTrack: 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: """ @@ -57,6 +88,7 @@ class VideoProbe: 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: @@ -93,6 +125,14 @@ async def probe_video(path: str) -> VideoProbe: 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). @@ -116,6 +156,8 @@ async def probe_video(path: str) -> VideoProbe: 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", "") @@ -142,6 +184,20 @@ async def probe_video(path: str) -> VideoProbe: 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 @@ -155,4 +211,5 @@ async def probe_video(path: str) -> VideoProbe: height=height, raw_codec_name=raw_codec_name, audio_tracks=audio_tracks, + subtitle_tracks=subtitle_tracks, ) |