diff --git a/docs/guide/subtitles.md b/docs/guide/subtitles.md index ec7e7a74..4df5b0be 100644 --- a/docs/guide/subtitles.md +++ b/docs/guide/subtitles.md @@ -35,7 +35,9 @@ shown in track listings. The **Extension** column is the file extension used on `fTTML` (STPP) and `fVTT` (WVTT) are the fragmented, MP4-box-encapsulated forms that DASH and HLS deliver. You never keep these as-is: right after download they are unwrapped into their plain counterparts (`fTTML` becomes TTML and `fVTT` becomes - WebVTT), so you always end up with an editable text subtitle. Note that this is a + WebVTT), so you always end up with an editable text subtitle. For a DASH track, the + unwrap also subtracts the manifest's `presentationTimeOffset` from the cue times, so the + subtitles stay in sync with the video. Note that this is a one-way, source-only relationship: you can convert *from* these boxed formats, but no backend can write them as an output target, so they can never be a conversion destination. diff --git a/tests/tracks/test_subtitle_convert.py b/tests/tracks/test_subtitle_convert.py index 15408dd7..44c3d96d 100644 --- a/tests/tracks/test_subtitle_convert.py +++ b/tests/tracks/test_subtitle_convert.py @@ -374,6 +374,82 @@ def test_segmented_fttml_parses_and_converts(tmp_path, monkeypatch): assert cue_count(out) == 2 +def dash_subtitle(tmp_path, monkeypatch, codec, data: bytes, template: str): + """A DASH Subtitle whose base download is a no-op, so download() runs only the post-processing on data.""" + from lxml import etree + + from unshackle.core.tracks.track import Track + + monkeypatch.setattr("unshackle.core.config.config.subtitle", {}, raising=False) + monkeypatch.setattr(Track, "download", lambda *args, **kwargs: None) + sub = Subtitle( + url="https://example.test/x", + language="en", + codec=codec, + descriptor=Track.Descriptor.DASH, + data={ + "dash": { + "adaptation_set": etree.fromstring(f"{template}"), + "representation": etree.fromstring(""), + "segment_durations": [], + } + }, + ) + sub.path = tmp_path / f"seg.{codec.extension}" + sub.path.write_bytes(data) + return sub + + +STPP_CUE = ( + '' + '
' + '

Line

' + "
" +) + + +@pytest.mark.parametrize( + "template, offset, times", + [ + ( + '' + '', + (2000, 6000), + (360_000_000, 360_839_000), + ), + # cues already on presentation time stay where they are + ( + '', + (3_600_000, 6000), + (362_000_000, 362_839_000), + ), + ], +) +def test_dash_fttml_cues_lose_the_presentation_time_offset(tmp_path, monkeypatch, template, offset, times): + sub = dash_subtitle(tmp_path, monkeypatch, Codec.fTTML, build_stpp_mp4(STPP_CUE), template) + assert sub.dash_offset_ms() == offset + + sub.download(None, None) # type: ignore[arg-type] + + assert sub.codec == Codec.TimedTextMarkupLang + (caption,) = Subtitle.parse(sub.path.read_bytes(), Codec.TimedTextMarkupLang).get_captions("en") + assert (caption.start, caption.end) == times + + +def test_dash_fvtt_cues_lose_the_presentation_time_offset(tmp_path, monkeypatch): + template = '' + data = (FIXTURES / "segmented.wvtt.mp4").read_bytes() + sub = dash_subtitle(tmp_path, monkeypatch, Codec.fVTT, data, template) + + sub.download(None, None) # type: ignore[arg-type] + + assert sub.codec == Codec.WebVTT + text = sub.path.read_text("utf8") + assert "00:00:00.000 --> 00:00:01.000" in text + assert "00:00:02.000 --> 00:00:03.000" in text + + def test_fttml_with_viewport_units_converts(tmp_path, monkeypatch): """TTML2 ``vw``/``vh`` and ``rw``/``rh`` sizes, which pycaption rejects, still parse and convert. diff --git a/unshackle/core/tracks/subtitle.py b/unshackle/core/tracks/subtitle.py index 8a993f86..74b65f74 100644 --- a/unshackle/core/tracks/subtitle.py +++ b/unshackle/core/tracks/subtitle.py @@ -271,24 +271,15 @@ class Subtitle(Track): if self.codec == Subtitle.Codec.fTTML: self.convert(Subtitle.Codec.TimedTextMarkupLang) + self.rebase_ttml(*self.dash_offset_ms()) elif self.codec == Subtitle.Codec.fVTT: self.convert(Subtitle.Codec.WebVTT) + self.path.write_text(rebase_webvtt(self.path.read_text("utf8"), *self.dash_offset_ms()), encoding="utf8") elif self.codec == Subtitle.Codec.WebVTT: text = self.path.read_text("utf8") text = Subtitle.strip_webvtt_timestamp_map(text) if self.descriptor == Track.Descriptor.DASH: - from unshackle.core.manifests import DASH - - template = DASH.merge_segment_templates( - self.data["dash"]["adaptation_set"], self.data["dash"]["representation"] - ) - if template is not None: - timescale = int(template.get("timescale", "1")) - text = rebase_webvtt( - text, - int(template.get("presentationTimeOffset", "0")) * 1000 // timescale, - int(template.get("duration", "0")) * 1000 // timescale, - ) + text = rebase_webvtt(text, *self.dash_offset_ms()) if len(self.data["dash"]["segment_durations"]) > 1: text = merge_segmented_webvtt( text, @@ -334,6 +325,48 @@ class Subtitle(Track): # Keep the sanitized version even if parsing failed self.path.write_text(text, encoding="utf8") + def dash_offset_ms(self) -> tuple[int, int]: + """ + The DASH presentationTimeOffset and one segment's length, in ms, from the merged SegmentTemplate. + Returns (0, 0) for a track that is not DASH or has no SegmentTemplate. + """ + if self.descriptor != Track.Descriptor.DASH: + return 0, 0 + from unshackle.core.manifests import DASH + + # SegmentBase/SegmentList offsets and later periods of a multi-period MPD are not read; + # add them when a stream needs it. + dash = self.data["dash"] + template = DASH.merge_segment_templates(dash["adaptation_set"], dash["representation"]) + if template is None: + return 0, 0 + timescale = int(template.get("timescale", "1")) + first = template.find("SegmentTimeline/S") + duration = template.get("duration") or (first is not None and first.get("d")) or "0" + return int(template.get("presentationTimeOffset", "0")) * 1000 // timescale, int(duration) * 1000 // timescale + + def rebase_ttml(self, offset_ms: int, segment_ms: int) -> None: + """ + Move the TTML cues back by offset_ms, under the same first-cue guard as rebase_webvtt. + A cue carried in from before the offset starts at 0; this method drops a cue that ends by then. + """ + if offset_ms <= 0 or not self.path: + return + caption_set = Subtitle.parse(self.path.read_bytes(), Subtitle.Codec.TimedTextMarkupLang) + languages = caption_set.get_languages() + offset_us = offset_ms * 1000 + if not languages or caption_set.get_captions(languages[0])[0].start < offset_us - segment_ms * 1000: + return + for language in languages: + kept = CaptionList() + for caption in caption_set.get_captions(language): + caption.start = max(caption.start - offset_us, 0) + caption.end -= offset_us + if caption.end > 0: + kept.append(caption) + caption_set.set_captions(language, kept) + self.path.write_text(pycaption.DFXPWriter().write(caption_set), encoding="utf8") + @staticmethod def strip_webvtt_cue_classes(text: str) -> str: """ diff --git a/unshackle/core/utils/webvtt.py b/unshackle/core/utils/webvtt.py index ec2c0eb8..aa67067f 100644 --- a/unshackle/core/utils/webvtt.py +++ b/unshackle/core/utils/webvtt.py @@ -85,6 +85,8 @@ def rebase_webvtt(text: str, offset_ms: int, segment_ms: int = 0) -> str: period whose presentationTimeOffset is offset_ms, as a live replay's do. Cues that start more than one segment below the offset are already presentation times, so the text stays as it is. A cue carried into the first segment can start up to one segment early. + The guard only tells the two timelines apart when the offset is much larger than a segment, + as a live clock is. It always subtracts an offset smaller than a segment. """ first = CUE_TIMES.search(text) if offset_ms <= 0 or not first or timestamp_ms(first[2]) < offset_ms - segment_ms: