fix(subtitle): place fragment-relative ISM TTML cues on the track timeline

Smooth Streaming TTML times cues from each fragment's start; a whole track stacked into the first fragment. Also stop rebase_ttml failing on a track with no cues.
This commit is contained in:
imSp4rky
2026-10-30 23:40:46 -06:00
parent 0c45195fe2
commit cbeff379fb
4 changed files with 149 additions and 28 deletions
+2 -1
View File
@@ -37,7 +37,8 @@ shown in track listings. The **Extension** column is the file extension used on
unwrapped into their plain counterparts (`fTTML` becomes TTML and `fVTT` becomes unwrapped into their plain counterparts (`fTTML` becomes TTML and `fVTT` becomes
WebVTT), so you always end up with an editable text subtitle. For a DASH track, the 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 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 subtitles stay in sync with the video. For an ISM track whose cues count from the start of
each fragment, the unwrap moves every cue onto the timeline of the track. Note that this is a
one-way, source-only relationship: you can convert *from* these boxed formats, but no 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 backend can write them as an output target, so they can never be a conversion
destination. destination.
+77 -19
View File
@@ -22,6 +22,7 @@ import pytest
from unshackle.core import binaries from unshackle.core import binaries
from unshackle.core.tracks import subtitle_convert as sc from unshackle.core.tracks import subtitle_convert as sc
from unshackle.core.tracks.subtitle import Subtitle from unshackle.core.tracks.subtitle import Subtitle
from unshackle.core.tracks.track import Track
Codec = Subtitle.Codec Codec = Subtitle.Codec
@@ -374,32 +375,29 @@ def test_segmented_fttml_parses_and_converts(tmp_path, monkeypatch):
assert cue_count(out) == 2 assert cue_count(out) == 2
def dash_subtitle(tmp_path, monkeypatch, codec, data: bytes, template: str): def downloaded_subtitle(tmp_path, monkeypatch, codec, data: bytes, descriptor: Track.Descriptor, track_data: dict):
"""A DASH Subtitle whose base download is a no-op, so download() runs only the post-processing on data.""" """A 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("unshackle.core.config.config.subtitle", {}, raising=False)
monkeypatch.setattr(Track, "download", lambda *args, **kwargs: None) monkeypatch.setattr(Track, "download", lambda *args, **kwargs: None)
sub = Subtitle( sub = Subtitle(url="https://example.test/x", language="en", codec=codec, descriptor=descriptor, data=track_data)
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 = tmp_path / f"seg.{codec.extension}"
sub.path.write_bytes(data) sub.path.write_bytes(data)
return sub return sub
def dash_subtitle(tmp_path, monkeypatch, codec, data: bytes, template: str):
from lxml import etree
track_data = {
"dash": {
"adaptation_set": etree.fromstring(f"<AdaptationSet>{template}</AdaptationSet>"),
"representation": etree.fromstring("<Representation/>"),
"segment_durations": [],
}
}
return downloaded_subtitle(tmp_path, monkeypatch, codec, data, Track.Descriptor.DASH, track_data)
STPP_CUE = ( STPP_CUE = (
'<?xml version="1.0" encoding="utf-8"?>' '<?xml version="1.0" encoding="utf-8"?>'
'<tt xmlns="http://www.w3.org/ns/ttml" xmlns:ttp="http://www.w3.org/ns/ttml#parameter"' '<tt xmlns="http://www.w3.org/ns/ttml" xmlns:ttp="http://www.w3.org/ns/ttml#parameter"'
@@ -450,6 +448,66 @@ def test_dash_fvtt_cues_lose_the_presentation_time_offset(tmp_path, monkeypatch)
assert "00:00:02.000 --> 00:00:03.000" in text assert "00:00:02.000 --> 00:00:03.000" in text
def ttml_fragment(*cues: tuple[str, str]) -> str:
body = "".join(f'<p begin="{begin}" end="{end}">Line</p>' for begin, end in cues)
return f'<tt xmlns="http://www.w3.org/ns/ttml" xml:lang="en"><body><div>{body}</div></body></tt>'
def test_dash_fttml_with_no_cues_converts(tmp_path, monkeypatch):
template = '<SegmentTemplate timescale="1000" presentationTimeOffset="2000" duration="6000"/>'
sub = dash_subtitle(tmp_path, monkeypatch, Codec.fTTML, build_stpp_mp4(ttml_fragment()), template)
sub.download(None, None) # type: ignore[arg-type]
assert sub.codec == Codec.TimedTextMarkupLang
@pytest.mark.parametrize(
"fragments, expected",
[
# timed from each fragment's start, as Smooth Streaming packagers write it
(
[
ttml_fragment(),
ttml_fragment(("00:00:06.000", "00:00:07.000")),
ttml_fragment(("00:00:00.500", "00:00:01.000")),
],
[(14.0, 15.0), (16.5, 17.0)],
),
# already on the stream timeline
(
[
ttml_fragment(),
ttml_fragment(("00:00:14.000", "00:00:15.000")),
ttml_fragment(("00:00:16.500", "00:00:17.000")),
],
[(14.0, 15.0), (16.5, 17.0)],
),
# one cue carried across a fragment boundary comes out whole
(
[
ttml_fragment(),
ttml_fragment(("00:00:07.000", "00:00:08.000")),
ttml_fragment(("00:00:00.000", "00:00:01.000")),
],
[(15.0, 17.0)],
),
],
)
def test_ism_fttml_cues_land_on_the_stream_timeline(tmp_path, monkeypatch, fragments, expected):
times = [0, 80_000_000, 160_000_000]
track_data = {"ism": {"segment_times": times, "fragment_times": times, "timescale": 10_000_000}}
sub = downloaded_subtitle(
tmp_path, monkeypatch, Codec.fTTML, build_stpp_mp4(*fragments), Track.Descriptor.ISM, track_data
)
sub.download(None, None) # type: ignore[arg-type]
assert sub.codec == Codec.TimedTextMarkupLang
captions = Subtitle.parse(sub.path.read_bytes(), Codec.TimedTextMarkupLang).get_captions("en")
assert [(c.start / 1e6, c.end / 1e6) for c in captions] == expected
def test_fttml_with_viewport_units_converts(tmp_path, monkeypatch): def test_fttml_with_viewport_units_converts(tmp_path, monkeypatch):
"""TTML2 ``vw``/``vh`` and ``rw``/``rh`` sizes, which pycaption rejects, still parse and convert. """TTML2 ``vw``/``vh`` and ``rw``/``rh`` sizes, which pycaption rejects, still parse and convert.
+3 -1
View File
@@ -416,7 +416,9 @@ class ISM:
segments: list[str] = track.data["ism"]["segments"] segments: list[str] = track.data["ism"]["segments"]
segment_times: list[int] = track.data["ism"].get("segment_times") or [] segment_times: list[int] = track.data["ism"].get("segment_times") or []
if segment_times: if segment_times:
segments = segments[track.sample_slice(segment_times, track.data["ism"]["timescale"], config.sample)] kept = track.sample_slice(segment_times, track.data["ism"]["timescale"], config.sample)
segments = segments[kept]
track.data["ism"]["fragment_times"] = segment_times[kept]
session_drm = None session_drm = None
if track.drm: if track.drm:
+67 -7
View File
@@ -269,7 +269,9 @@ class Subtitle(Track):
if not self.path: if not self.path:
return return
if self.codec == Subtitle.Codec.fTTML: if self.codec == Subtitle.Codec.fTTML and self.descriptor == Track.Descriptor.ISM:
self.unwrap_ism_ttml()
elif self.codec == Subtitle.Codec.fTTML:
self.convert(Subtitle.Codec.TimedTextMarkupLang) self.convert(Subtitle.Codec.TimedTextMarkupLang)
self.rebase_ttml(*self.dash_offset_ms()) self.rebase_ttml(*self.dash_offset_ms())
elif self.codec == Subtitle.Codec.fVTT: elif self.codec == Subtitle.Codec.fVTT:
@@ -353,11 +355,11 @@ class Subtitle(Track):
if offset_ms <= 0 or not self.path: if offset_ms <= 0 or not self.path:
return return
caption_set = Subtitle.parse(self.path.read_bytes(), Subtitle.Codec.TimedTextMarkupLang) caption_set = Subtitle.parse(self.path.read_bytes(), Subtitle.Codec.TimedTextMarkupLang)
languages = caption_set.get_languages()
offset_us = offset_ms * 1000 offset_us = offset_ms * 1000
if not languages or caption_set.get_captions(languages[0])[0].start < offset_us - segment_ms * 1000: first = Subtitle.first_cue_start(caption_set)
if first is None or first < offset_us - segment_ms * 1000:
return return
for language in languages: for language in caption_set.get_languages():
kept = CaptionList() kept = CaptionList()
for caption in caption_set.get_captions(language): for caption in caption_set.get_captions(language):
caption.start = max(caption.start - offset_us, 0) caption.start = max(caption.start - offset_us, 0)
@@ -367,6 +369,65 @@ class Subtitle(Track):
caption_set.set_captions(language, kept) caption_set.set_captions(language, kept)
self.path.write_text(pycaption.DFXPWriter().write(caption_set), encoding="utf8") self.path.write_text(pycaption.DFXPWriter().write(caption_set), encoding="utf8")
@staticmethod
def mdat_payloads(data: bytes) -> list[bytes]:
"""The payload of every mdat box in a fragmented MP4 file, in file order."""
with PYMP4_LOCK:
boxes = MP4.parse_stream(BytesIO(data))
return [box.data for box in boxes if box.type == b"mdat"]
@staticmethod
def first_cue_start(caption_set: pycaption.CaptionSet) -> Optional[int]:
"""The start of the first cue, in microseconds, looking past empty languages; None with no cues."""
return next(
(c.start for language in caption_set.get_languages() for c in caption_set.get_captions(language)[:1]),
None,
)
def unwrap_ism_ttml(self) -> None:
"""
Convert ISM fTTML to TTML with every cue on the stream timeline, counted from its first fragment.
Smooth Streaming packagers often time the cues of a fragment from that fragment's start. A cue
that starts before the previous fragment does marks the stream as timed that way.
"""
from unshackle.core.tracks.subtitle_convert import finalize
if not self.path:
return
ism = self.data["ism"]
times = ism.get("fragment_times") or []
payloads = Subtitle.mdat_payloads(self.path.read_bytes()) if times else []
captions: dict[str, CaptionList] = defaultdict(CaptionList)
if times and len(payloads) == len(times):
fragments = [Subtitle.parse(payload, Subtitle.Codec.TimedTextMarkupLang) for payload in payloads]
starts = [t * 1_000_000 // ism["timescale"] for t in times]
origin = ism["segment_times"][0] * 1_000_000 // ism["timescale"]
relative = any(
(first := Subtitle.first_cue_start(fragment)) is not None and first < previous
for previous, fragment in zip(starts, fragments[1:])
)
for start, fragment in zip(starts, fragments):
shift = (start if relative else 0) - origin
for language in fragment.get_languages():
kept = captions[language]
for caption in fragment.get_captions(language):
caption.start = max(caption.start + shift, 0)
caption.end += shift
if kept and kept[-1].end == caption.start and kept[-1].get_text() == caption.get_text():
kept[-1].end = caption.end # a cue carried into the next fragment
elif caption.end > 0:
kept.append(caption)
if not any(captions.values()):
logging.getLogger("Subtitle").debug(f"ISM text: {len(payloads)} fragments, {len(times)} fragment times")
self.convert(Subtitle.Codec.TimedTextMarkupLang)
return
caption_set = pycaption.CaptionSet(captions)
Subtitle.merge_same_cues(caption_set)
out = self.path.with_suffix(f".{Subtitle.Codec.TimedTextMarkupLang.value.lower()}")
out.write_text(pycaption.DFXPWriter().write(caption_set), encoding="utf8")
finalize(self, Subtitle.Codec.TimedTextMarkupLang, out)
@staticmethod @staticmethod
def strip_webvtt_cue_classes(text: str) -> str: def strip_webvtt_cue_classes(text: str) -> str:
""" """
@@ -771,10 +832,9 @@ class Subtitle(Track):
caption_set = pycaption.SRTReader().read(text) caption_set = pycaption.SRTReader().read(text)
elif codec == Subtitle.Codec.fTTML: elif codec == Subtitle.Codec.fTTML:
caption_lists: dict[Optional[str], pycaption.CaptionList] = defaultdict(pycaption.CaptionList) caption_lists: dict[Optional[str], pycaption.CaptionList] = defaultdict(pycaption.CaptionList)
with PYMP4_LOCK:
boxes = MP4.parse_stream(BytesIO(data))
for segment in ( for segment in (
Subtitle.parse(box.data, Subtitle.Codec.TimedTextMarkupLang) for box in boxes if box.type == b"mdat" Subtitle.parse(payload, Subtitle.Codec.TimedTextMarkupLang)
for payload in Subtitle.mdat_payloads(data)
): ):
for lang in segment.get_languages(): for lang in segment.get_languages():
caption_lists[lang].extend(segment.get_captions(lang)) caption_lists[lang].extend(segment.get_captions(lang))