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-hub/tests | |
| 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-hub/tests')
| -rw-r--r-- | packages/meshbay-hub/tests/test_video_audio_track.py | 6 | ||||
| -rw-r--r-- | packages/meshbay-hub/tests/test_video_subtitles.py | 232 |
2 files changed, 238 insertions, 0 deletions
diff --git a/packages/meshbay-hub/tests/test_video_audio_track.py b/packages/meshbay-hub/tests/test_video_audio_track.py index 534da6d..6eb98ba 100644 --- a/packages/meshbay-hub/tests/test_video_audio_track.py +++ b/packages/meshbay-hub/tests/test_video_audio_track.py @@ -72,6 +72,12 @@ def _label_cases(tmp_path, app, cases, locale="en"): script = tmp_path / "label.mjs" src = "\n".join([ app[app.index("const _ISO639 = {"):app.index("};", app.index("const _ISO639 = {")) + 2], + # The language name is shared with `subtitleTrackLabel`, so it lives in + # a function of its own and has to be lifted alongside its caller. A + # lift that names one function stops exercising anything the moment + # logic moves out of it — here it throws, which is the good case; the + # bad one is a lift that still runs and no longer covers the rule. + _lift(app, "_languageName"), _lift(app, "audioTrackLabel"), ]) script.write_text( diff --git a/packages/meshbay-hub/tests/test_video_subtitles.py b/packages/meshbay-hub/tests/test_video_subtitles.py new file mode 100644 index 0000000..9f0c08d --- /dev/null +++ b/packages/meshbay-hub/tests/test_video_subtitles.py @@ -0,0 +1,232 @@ +""" +Showing a subtitle track in the player. + +The node extracts one track whole, converts it to WebVTT and caches it under +its own hash; this side asks for it by ordinal, pulls the blob through the +ordinary chunk path and hangs a `<track>` on the video element. Four things +have to hold here: + +**The selector exists only where the node said there was something to show.** +It is drawn from `subtitle_tracks` in `stream_init` and from nothing else — +there is no version check in the player — so a node too old to enumerate them +draws no selector and is never sent a `subtitle_req` it would answer "unknown +message type" to. The list also carries only the tracks the node can convert, +so what the menu offers is what will actually appear. + +**The ordinal travels untouched.** It counts every subtitle stream in the +container, including the bitmap ones that are never listed, so it is not the +track's position in the list this client received. Renumbering it here would +map a different stream on the node and show the wrong language, or nothing. + +**A seek does not take the subtitles down.** The extraction is whole-file, so +the cues are absolute: the `<track>` outlives every restart of the +MediaSource, including the one an audio-language change produces. + +**The reply is matched to the request by file *and* track.** Two extractions +for one film can be in flight when the viewer changes their mind, and the +reply that arrives first is not necessarily the one asked for first. +""" + +import json +import re +import shutil +import subprocess +from pathlib import Path + +import pytest + +STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static" +APP = STATIC / "video-player.js" +TRANSPORT = STATIC / "transport.js" + +pytestmark = pytest.mark.skipif( + shutil.which("node") is None or not APP.exists(), + reason="node or the SPA sources are not available") + + +@pytest.fixture(scope="module") +def app(): + return APP.read_text() + + +@pytest.fixture(scope="module") +def transport(): + return TRANSPORT.read_text() + + +def _lift(src: str, name: str) -> str: + """One top-level function, as text, for node to execute.""" + start = src.index(f"function {name}(") + depth, i, seen = 0, start, False + while i < len(src): + if src[i] == "{": + depth += 1 + seen = True + elif src[i] == "}": + depth -= 1 + if seen and depth == 0: + return src[start:i + 1] + i += 1 + raise AssertionError(f"{name} never closes") + + +def _player(app: str) -> str: + """The whole component, by position in the file. + + Not the brace matcher below: `function VideoPlayer({ entry, ... })` + destructures its props, so counting from the first `{` closes on the + parameter list and returns the signature alone — a lift that finds + nothing it was asked about and asserts happily against an empty string. + """ + i = app.index("function VideoPlayer(") + nxt = app.find("\nfunction ", i + 1) + return app[i:nxt if nxt > 0 else len(app)] + + +def _block(src: str, opener: str) -> str: + """The body of one `x = (args) => {` assignment, braces matched.""" + start = src.index(opener) + depth, i, seen = 0, start, False + while i < len(src): + if src[i] == "{": + depth += 1 + seen = True + elif src[i] == "}": + depth -= 1 + if seen and depth == 0: + return src[start:i + 1] + i += 1 + raise AssertionError(f"{opener!r} never closes") + + +# ── The label, run rather than read ─────────────────────────────────────────── + +def _label_cases(tmp_path, app, cases, locale="en"): + script = tmp_path / "sublabel.mjs" + src = "\n".join([ + app[app.index("const _ISO639 = {"):app.index("};", app.index("const _ISO639 = {")) + 2], + _lift(app, "_languageName"), + _lift(app, "subtitleTrackLabel"), + ]) + script.write_text( + f"const getLocale = () => '{locale}';\n" + "const t = (k, p) => `${k}:${p.n}`;\n" + + src + + "\nconst out = JSON.parse(process.argv[2]).map(subtitleTrackLabel);\n" + "console.log(JSON.stringify(out));\n") + proc = subprocess.run( + ["node", str(script), json.dumps(cases)], + capture_output=True, text=True) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_the_language_is_named_not_shown_as_a_tag(tmp_path, app): + """Same fold as the audio tracks: ffprobe reports either ISO 639-2 variant + and a library uses both.""" + out = _label_cases(tmp_path, app, [ + {"i": 0, "lang": "fre"}, + {"i": 1, "lang": "fra"}, + {"i": 2, "lang": "eng"}, + ]) + assert out[0] == out[1], f"fre and fra must agree: {out}" + assert "French" in out[0] and "English" in out[2], out + + +def test_the_container_title_tells_a_forced_track_from_a_full_one(tmp_path, app): + """The difference the title carries is not cosmetic here. + + "Forced" and "SDH" are tagged with the same language as the ordinary track + they sit beside. Dropping the title makes them one menu entry repeated, + and picking the wrong one is the difference between a full translation and + three lines of signage in a whole film. + """ + out = _label_cases(tmp_path, app, [ + {"i": 0, "lang": "eng", "title": "Forced"}, + {"i": 1, "lang": "eng", "title": "SDH"}, + {"i": 2, "lang": "eng"}, + ]) + assert len({*out}) == 3, f"three tracks must give three entries: {out}" + assert "Forced" in out[0] and "SDH" in out[1] + + +def test_an_untagged_track_is_numbered_not_called_unknown(tmp_path, app): + """A file with no language tags still needs distinguishable entries.""" + out = _label_cases(tmp_path, app, [{"i": 3, "lang": None}, {"i": 4, "lang": "und"}]) + assert out == ["video.subtitle_track_n:4", "video.subtitle_track_n:5"], out + + +# ── The shape of the feature, read from the source ──────────────────────────── + +def test_the_selector_comes_from_the_node_list_and_not_from_a_version(app): + """Discovery from the answer. There is no version check in the player, and + an empty list is the whole of "this node has no subtitles for you".""" + assert "msg.subtitle_tracks" in app + assert "subtitleTracks.length > 0 &&" in app, ( + "the selector must be drawn from the node's list") + for forbidden in ("MNP_VERSION", "v >= '3.3'", "'3.3'"): + assert forbidden not in app, ( + f"the player decided on {forbidden!r} instead of on the answer") + + +def test_the_ordinal_is_passed_through_rather_than_renumbered(app): + """`track.i` is the node's ordinal; the list index is not it.""" + assert "requestSubtitle(entry.id, track.i)" in app, ( + "the request must carry the ordinal the node published") + assert not re.search(r"requestSubtitle\([^)]*\bindex\b", app), ( + "a list position was sent where a stream ordinal belongs") + + +def test_a_seek_does_not_take_the_subtitles_down(app): + """The payoff of extracting whole-file rather than per-seek. + + `stream_init` arrives again on every seek and on every audio-language + change. It may restate the available tracks; it must not clear the chosen + one or revoke the blob, or every seek would re-extract and the track would + blink out mid-film. + """ + body = _block(app, "transport.onStreamInit = (msg) => {") + assert "setSubtitleTracks(" in body + for forbidden in ("setSubtitleUrl(", "setSubtitleTrack(", "subtitleUrlRef.current ="): + assert forbidden not in body, ( + f"stream_init touches {forbidden!r}, so a seek disturbs the track") + + +def test_the_blob_is_pulled_through_the_ordinary_chunk_path(app): + """Not a new transfer mechanism — the same indirection as a poster.""" + assert "pipelinedDownload(" in app + assert "info.hash" in app, "the cache hash from the reply is what is fetched" + + +def test_a_stale_reply_cannot_install_its_track(app): + """Two extractions in flight, and the last answered is not the last asked. + + Without the generation the older reply overwrites the newer choice, and + the menu then shows a tick against a language that is not on screen. + """ + body = _player(app) + assert "subtitleGenRef.current !== gen" in body, ( + "nothing stops an out-of-date extraction from installing its blob") + assert "URL.revokeObjectURL(url)" in body, ( + "the superseded blob must be released, not merely ignored") + + +def test_the_reply_is_matched_on_the_file_and_the_track(transport): + """One film's two tracks are exactly the pair that can be in flight + together, so the file id alone cannot route the reply.""" + assert "`subtitle:${obj.file_id}:${obj.track}`" in transport + assert "`subtitle:${msg.file_id}:${msg.track}`" in transport + + +def test_a_failed_extraction_does_not_take_the_film_down(app): + """Subtitles are an addition to a film that is already playing. + + `setError` is the player's fatal path — it replaces the picture. A text + track that could not be read must not reach it. + """ + body = _player(app) + start = body.index("const selectSubtitle = useCallback(") + end = body.index("}, [entry, transportRef, gekRef]);", start) + assert "setError(" not in body[start:end], ( + "a subtitle failure takes the whole player down") + assert "setSubtitleError(true)" in body[start:end] |