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 = (
+ ''
+ '"
+)
+
+
+@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: