mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-11-11 19:45:06 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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:
|
||||
"""
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user