fix(subtitle): remove the DASH presentationTimeOffset from stpp and wvtt cues

STPP and WVTT cues sit on the media timeline, so they played late by the offset after the mux.
This commit is contained in:
imSp4rky
2026-10-30 23:22:47 -06:00
parent 97711e799c
commit 0c45195fe2
4 changed files with 126 additions and 13 deletions
+3 -1
View File
@@ -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.
+76
View File
@@ -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"<AdaptationSet>{template}</AdaptationSet>"),
"representation": etree.fromstring("<Representation/>"),
"segment_durations": [],
}
},
)
sub.path = tmp_path / f"seg.{codec.extension}"
sub.path.write_bytes(data)
return sub
STPP_CUE = (
'<?xml version="1.0" encoding="utf-8"?>'
'<tt xmlns="http://www.w3.org/ns/ttml" xmlns:ttp="http://www.w3.org/ns/ttml#parameter"'
' ttp:timeBase="media" xml:lang="en"><body><div>'
'<p begin="00:06:02.000" end="00:06:02.839">Line</p>'
"</div></body></tt>"
)
@pytest.mark.parametrize(
"template, offset, times",
[
(
'<SegmentTemplate timescale="90000" presentationTimeOffset="180000">'
'<SegmentTimeline><S t="180000" d="540000" r="10"/></SegmentTimeline></SegmentTemplate>',
(2000, 6000),
(360_000_000, 360_839_000),
),
# cues already on presentation time stay where they are
(
'<SegmentTemplate timescale="1000" presentationTimeOffset="3600000" duration="6000"/>',
(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 = '<SegmentTemplate timescale="1000" presentationTimeOffset="1000" duration="2000"/>'
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.
+45 -12
View File
@@ -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:
"""
+2
View File
@@ -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: