summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-17 15:06:57 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-17 15:06:57 +0200
commitd472725167c1d335996059d24b7b74f3730768fb (patch)
tree1ffca323c9dfe682c8fff2a20da1cfae0106f830
parent30980ba0994d3e99d41c5252555320d6cbb1f5a9 (diff)
downloadmeshbay-d472725167c1d335996059d24b7b74f3730768fb.tar.gz
feat: tell a forced subtitle track from a full one
Reported as "I click a subtitle and nothing appears", on three films. Nothing was broken. The track selected was the container's forced track, which carries signage and foreign dialogue only: measured on the film in question, 30 cues and 77 seconds of text across 2h32 — 0.8% of the running time, against 1559 cues and 41.8% for the full track sitting beside it under the same language tag. At all three positions tested there was genuinely no cue to show; the full track would have shown one at two of them. So the defect is that the menu could not say which was which. The label used the container's title tag, which said "Forced" on that film and says nothing at all on most, and no other field was carried. The disposition is the half that is always there: `probe_video` now reads `forced` and `hearing_impaired`, `stream_init` carries them, and the label states them in the reader's own language rather than repeating an English word a muxer happened to type. The node fixture grows a forced track with no title, because a title would let the old code pass. The label harness's `t` stub took a parameters object unconditionally and threw on a key that has none — a fixture narrower than production, fixed here rather than worked around. Also removes the activeCues probe that found this. It answered its question: mode showing, cues 30, active 0, none due at that instant. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UGY17EPph5LsLzePPXhUVc
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/de.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/en.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/es.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/it.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/video-player.js37
-rw-r--r--packages/meshbay-hub/tests/test_video_subtitles.py37
-rw-r--r--packages/meshbay-node/src/meshbay_node/media_probe.py14
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py6
-rw-r--r--packages/meshbay-node/tests/test_stream_subtitle_tracks.py31
15 files changed, 116 insertions, 29 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
index dd53bcf..9d9319d 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
@@ -254,6 +254,8 @@ export default {
'video.subtitles': 'Untertitel',
'video.subtitles_off': 'Aus',
'video.subtitle_track_n': 'Spur {n}',
+ 'video.subtitles_forced': 'erzwungen (nur Schrifttafeln)',
+ 'video.subtitles_sdh': 'für Hörgeschädigte',
'video.err_subtitle': 'Diese Untertitelspur konnte nicht geladen werden',
'video.close': 'Schließen (Esc)',
'preview.pdf_fallback': 'Dieser Browser zeigt das PDF nicht direkt an. Laden Sie es '
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
index e9f3d8d..a928a52 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
@@ -253,6 +253,8 @@ export default {
'video.subtitles': 'Subtitles',
'video.subtitles_off': 'Off',
'video.subtitle_track_n': 'Track {n}',
+ 'video.subtitles_forced': 'forced (signs only)',
+ 'video.subtitles_sdh': 'for the deaf and hard of hearing',
'video.err_subtitle': 'Could not load this subtitle track',
'video.close': 'Close (Esc)',
'preview.pdf_fallback': 'This browser will not display the PDF inline. Download it instead — it was decrypted here either way.',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
index 68fbe10..2e6ea26 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
@@ -252,6 +252,8 @@ export default {
'video.subtitles': 'Subtítulos',
'video.subtitles_off': 'Ninguno',
'video.subtitle_track_n': 'Pista {n}',
+ 'video.subtitles_forced': 'forzados (solo carteles)',
+ 'video.subtitles_sdh': 'para sordos',
'video.err_subtitle': 'No se pudo cargar esta pista de subtítulos',
'video.close': 'Cerrar (Esc)',
'preview.pdf_fallback': 'Este navegador no mostrará el PDF integrado. Descárguelo '
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
index 679a458..5e6f258 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
@@ -253,6 +253,8 @@ export default {
'video.subtitles': 'Sous-titres',
'video.subtitles_off': 'Aucun',
'video.subtitle_track_n': 'Piste {n}',
+ 'video.subtitles_forced': 'forcés (panneaux seulement)',
+ 'video.subtitles_sdh': 'sourds et malentendants',
'video.err_subtitle': 'Impossible de charger cette piste de sous-titres',
'video.close': 'Fermer (Échap)',
'preview.pdf_fallback': 'Ce navigateur n’affichera pas le PDF directement. '
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
index 9a34c93..ba594d2 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
@@ -253,6 +253,8 @@ export default {
'video.subtitles': 'Sottotitoli',
'video.subtitles_off': 'Nessuno',
'video.subtitle_track_n': 'Traccia {n}',
+ 'video.subtitles_forced': 'forzati (solo cartelli)',
+ 'video.subtitles_sdh': 'per non udenti',
'video.err_subtitle': 'Impossibile caricare questa traccia di sottotitoli',
'video.close': 'Chiudi (Esc)',
'preview.pdf_fallback': 'Questo browser non mostrerà il PDF nella pagina. Lo scarichi '
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
index 1d58e2c..9bbafb2 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
@@ -250,6 +250,8 @@ export default {
'video.subtitles': '字幕',
'video.subtitles_off': 'なし',
'video.subtitle_track_n': 'トラック {n}',
+ 'video.subtitles_forced': '強制(表示物のみ)',
+ 'video.subtitles_sdh': '聴覚障害者向け',
'video.err_subtitle': 'この字幕トラックを読み込めませんでした',
'video.close': '閉じる(Esc)',
'preview.pdf_fallback': 'このブラウザーはページ内に PDF を表示しません。'
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
index 5bef61f..8f28d0c 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
@@ -254,6 +254,8 @@ export default {
'video.subtitles': 'Ondertiteling',
'video.subtitles_off': 'Geen',
'video.subtitle_track_n': 'Spoor {n}',
+ 'video.subtitles_forced': 'geforceerd (alleen tekstborden)',
+ 'video.subtitles_sdh': 'voor doven en slechthorenden',
'video.err_subtitle': 'Dit ondertitelspoor kon niet worden geladen',
'video.close': 'Sluiten (Esc)',
'preview.pdf_fallback': 'Deze browser toont de PDF niet in de pagina zelf. Download '
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
index 8989880..fadf622 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
@@ -259,6 +259,8 @@ export default {
'video.subtitles': 'Napisy',
'video.subtitles_off': 'Brak',
'video.subtitle_track_n': 'Ścieżka {n}',
+ 'video.subtitles_forced': 'wymuszone (tylko napisy w tle)',
+ 'video.subtitles_sdh': 'dla niesłyszących',
'video.err_subtitle': 'Nie udało się wczytać tej ścieżki napisów',
'video.close': 'Zamknij (Esc)',
'preview.pdf_fallback': 'Ta przeglądarka nie wyświetli pliku PDF na stronie. Proszę '
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
index f1b8643..39da2b2 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
@@ -254,6 +254,8 @@ export default {
'video.subtitles': 'Legendas',
'video.subtitles_off': 'Nenhuma',
'video.subtitle_track_n': 'Faixa {n}',
+ 'video.subtitles_forced': 'forçadas (apenas letreiros)',
+ 'video.subtitles_sdh': 'para surdos',
'video.err_subtitle': 'Não foi possível carregar esta faixa de legendas',
'video.close': 'Fechar (Esc)',
'preview.pdf_fallback': 'Este navegador não exibirá o PDF na própria página. Baixe '
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
index 91da464..8639592 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
@@ -248,6 +248,8 @@ export default {
'video.subtitles': '字幕',
'video.subtitles_off': '关闭',
'video.subtitle_track_n': '字幕 {n}',
+ 'video.subtitles_forced': '强制(仅字幕牌)',
+ 'video.subtitles_sdh': '听障人士专用',
'video.err_subtitle': '无法加载此字幕轨道',
'video.close': '关闭(Esc)',
'preview.pdf_fallback': '此浏览器不会在页面内显示该 PDF。请改为下载——无论如何它都已在本地解密。',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/video-player.js b/packages/meshbay-hub/src/meshbay_hub/static/video-player.js
index 3bb9124..1522539 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/video-player.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/video-player.js
@@ -113,7 +113,15 @@ function audioTrackLabel(track) {
function subtitleTrackLabel(track) {
const name = _languageName(track.lang)
|| t('video.subtitle_track_n', { n: track.i + 1 });
- return track.title ? `${name} — ${track.title}` : name;
+ // The disposition wins over the container's title, and is translated, so a
+ // forced track reads as one in the viewer's own language rather than as the
+ // English word a muxer happened to type — or as nothing at all, which is
+ // what a forced track with no title tag looked like.
+ const kind = track.forced ? t('video.subtitles_forced')
+ : track.sdh ? t('video.subtitles_sdh')
+ : null;
+ const detail = kind || track.title;
+ return detail ? `${name} — ${detail}` : name;
}
function _mseSupported(codec) {
@@ -1159,33 +1167,6 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) {
'mode =', v.textTracks[0] && v.textTracks[0].mode,
'cues =', v.textTracks[0] && v.textTracks[0].cues
? v.textTracks[0].cues.length : 'none');
- if (!subtitleUrl) return;
- // The state above is a single instant, and every part of it can be right
- // there and wrong a second later: a re-render can replace the <track>,
- // which resets the mode nothing would set again. What decides whether a
- // subtitle is on screen is `activeCues`, and that is the one number no
- // log has ever carried. Sampled for ten seconds, with the cue that ought
- // to be showing at this instant, so "none active" and "active but not
- // painted" stop being the same observation.
- let n = 0;
- const probe = setInterval(() => {
- const tt = v.textTracks[0];
- if (!tt) { console.log('[MeshBay] subtitle probe: the track is gone'); return; }
- const cues = tt.cues ? Array.from(tt.cues) : [];
- const now = v.currentTime;
- const should = cues.find((c) => c.startTime <= now && c.endTime >= now);
- console.log('[MeshBay] subtitle probe', ++n,
- 't =', now.toFixed(1), 'mode =', tt.mode,
- 'cues =', cues.length,
- 'active =', tt.activeCues ? tt.activeCues.length : 'null',
- 'due here =', should ? `[${should.startTime.toFixed(1)}-`
- + `${should.endTime.toFixed(1)}] ${should.text.slice(0, 40)}` : 'none',
- 'first/last =', cues.length
- ? `${cues[0].startTime.toFixed(1)}..${cues[cues.length - 1].endTime.toFixed(1)}`
- : '-');
- if (n >= 5) clearInterval(probe);
- }, 2000);
- return () => clearInterval(probe);
}, [subtitleUrl]);
useEffect(() => {
diff --git a/packages/meshbay-hub/tests/test_video_subtitles.py b/packages/meshbay-hub/tests/test_video_subtitles.py
index 9f0c08d..6867076 100644
--- a/packages/meshbay-hub/tests/test_video_subtitles.py
+++ b/packages/meshbay-hub/tests/test_video_subtitles.py
@@ -110,7 +110,10 @@ def _label_cases(tmp_path, app, cases, locale="en"):
])
script.write_text(
f"const getLocale = () => '{locale}';\n"
- "const t = (k, p) => `${k}:${p.n}`;\n"
+ # Not every key is interpolated — the forced/SDH qualifiers take no
+ # parameters — and a stub that assumes one throws where the real `t`
+ # returns a string. A fixture narrower than production tests itself.
+ "const t = (k, p) => (p ? `${k}:${p.n}` : k);\n"
+ src
+ "\nconst out = JSON.parse(process.argv[2]).map(subtitleTrackLabel);\n"
"console.log(JSON.stringify(out));\n")
@@ -150,6 +153,38 @@ def test_the_container_title_tells_a_forced_track_from_a_full_one(tmp_path, app)
assert "Forced" in out[0] and "SDH" in out[1]
+def test_a_forced_track_is_named_as_one_in_the_reader_s_language(tmp_path, app):
+ """The entry that was reported as a broken feature.
+
+ A forced track shows signage and foreign dialogue only — on a real film,
+ 77 seconds of text across 2h32 — so picking it and seeing nothing is its
+ normal behaviour. It has to be possible to tell it from the full track
+ beside it, which carries the same language tag, and the disposition says
+ so where the container's English title tag is often simply absent.
+ """
+ out = _label_cases(tmp_path, app, [
+ {"i": 0, "lang": "fre", "forced": True},
+ {"i": 1, "lang": "fre"},
+ {"i": 2, "lang": "eng", "sdh": True},
+ ])
+ assert out[0] != out[1], f"a forced track must not read like the full one: {out}"
+ # The catalogues are stubbed here, so what is asserted is that the label
+ # goes through `t` at all: the qualifier has to be translated, not the
+ # English word a muxer typed into the container.
+ assert out[0] == 'French — video.subtitles_forced', out
+ assert out[1] == 'French', out
+ assert out[2] == 'English — video.subtitles_sdh', out
+
+
+def test_the_disposition_wins_over_the_container_title(tmp_path, app):
+ """A muxer's "Forced" is one spelling of many, and in one language."""
+ out = _label_cases(tmp_path, app, [
+ {"i": 0, "lang": "fre", "title": "FORCE VF", "forced": True},
+ ])
+ assert 'FORCE VF' not in out[0], out
+ assert out[0] == 'French — video.subtitles_forced', out
+
+
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"}])
diff --git a/packages/meshbay-node/src/meshbay_node/media_probe.py b/packages/meshbay-node/src/meshbay_node/media_probe.py
index 06b1e28..81174c8 100644
--- a/packages/meshbay-node/src/meshbay_node/media_probe.py
+++ b/packages/meshbay-node/src/meshbay_node/media_probe.py
@@ -69,6 +69,16 @@ class SubtitleTrack:
language: str | None
title: str | None
codec_name: str | None
+ # **A forced track is not a shorter version of the full one.** It carries
+ # only signage and the lines spoken in another language — measured on a
+ # real film: 30 cues and 77 seconds of text across 2h32, against 1559 cues
+ # and 41% of the running time for the full track beside it. Picking it and
+ # seeing nothing for ten minutes is the ordinary outcome, not a fault, and
+ # nothing in the menu let a viewer tell those two apart. The disposition is
+ # what says which it is; the title tag that would also say it ("Forced",
+ # "SDH") is absent as often as it is present.
+ forced: bool = False
+ hearing_impaired: bool = False
@dataclass
@@ -143,6 +153,7 @@ async def probe_video(path: str) -> VideoProbe:
"-show_entries",
"stream=codec_name,profile,level,codec_type,width,height,channels",
"-show_entries", "stream_tags=language,title",
+ "-show_entries", "stream_disposition=forced,hearing_impaired",
"-show_entries", "format=duration",
"-of", "json", path,
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
@@ -192,11 +203,14 @@ async def probe_video(path: str) -> VideoProbe:
if codec_name not in TEXT_SUBTITLE_CODECS:
continue
tags = s.get("tags") or {}
+ disp = s.get("disposition") 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,
+ forced=bool(disp.get("forced")),
+ hearing_impaired=bool(disp.get("hearing_impaired")),
))
has_audio = bool(audio_tracks)
diff --git a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
index 1e85f29..2863679 100644
--- a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
+++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
@@ -6267,6 +6267,12 @@ class WebRTCPeerSession:
"lang": tr.language,
"title": tr.title,
"codec": tr.codec_name,
+ # What tells a full translation from signage-only. Without
+ # it the two are the same menu entry, and picking the
+ # forced one shows nothing for minutes at a time — which
+ # reads as a broken feature and was reported as one.
+ "forced": tr.forced,
+ "sdh": tr.hearing_impaired,
}
for tr in probe.subtitle_tracks
],
diff --git a/packages/meshbay-node/tests/test_stream_subtitle_tracks.py b/packages/meshbay-node/tests/test_stream_subtitle_tracks.py
index cafb2e3..9a09edb 100644
--- a/packages/meshbay-node/tests/test_stream_subtitle_tracks.py
+++ b/packages/meshbay-node/tests/test_stream_subtitle_tracks.py
@@ -95,6 +95,9 @@ def _make_subtitled_clip(path: Path) -> None:
"-metadata:s:s:0", "language=fre",
"-metadata:s:s:1", "language=fre",
"-metadata:s:s:2", "language=eng",
+ # Ordinal 1 is the forced one, and carries no title saying so — which
+ # is the case the disposition exists for.
+ "-disposition:s:1", "forced",
str(path)],
check=True, capture_output=True)
@@ -209,6 +212,31 @@ def test_bitmap_codecs_are_not_offered():
@pytest.mark.asyncio
+async def test_a_forced_track_says_so_without_needing_a_title(tmp_path):
+ """The distinction a viewer cannot make for themselves.
+
+ A forced track carries signage and foreign dialogue only — on a real film,
+ 30 cues and 77 seconds of text across 2h32, against 1559 cues and 41% of
+ the running time for the full track beside it. Selecting it and seeing
+ nothing for ten minutes is its normal behaviour, and was reported as a
+ broken feature. The container's title tag would say it too, when it is
+ there; the disposition is the half that is always there.
+ """
+ clip = tmp_path / "clip.mp4"
+ _make_subtitled_clip(clip)
+
+ probe = await _probe_video(str(clip))
+ by_ordinal = {tr.ordinal: tr for tr in probe.subtitle_tracks}
+
+ assert by_ordinal[1].forced is True
+ assert by_ordinal[2].forced is False
+ assert by_ordinal[1].title is None, (
+ "the fixture must carry no title on the forced track, or it does not "
+ "exercise the case the disposition is for")
+ assert all(not tr.hearing_impaired for tr in probe.subtitle_tracks)
+
+
+@pytest.mark.asyncio
async def test_stream_init_announces_the_tracks(tmp_path):
"""How a client discovers this node can do subtitles at all.
@@ -226,6 +254,9 @@ async def test_stream_init_announces_the_tracks(tmp_path):
init = next(m for m in session.sent if m.get("type") == "stream_init")
assert [tr["i"] for tr in init["subtitle_tracks"]] == [1, 2]
assert [tr["lang"] for tr in init["subtitle_tracks"]] == ["fre", "eng"]
+ assert [tr["forced"] for tr in init["subtitle_tracks"]] == [True, False], (
+ "the client cannot mark a forced track it was never told about")
+ assert [tr["sdh"] for tr in init["subtitle_tracks"]] == [False, False]
@pytest.mark.parametrize("track", [1, 2])