diff options
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]) |