mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-11-12 20:15:10 +00:00
fix(subtitle): join cues that a segment boundary splits in stpp and wvtt
Share one append_cue join across DASH fTTML, ISM and fVTT parsing. Also map "geo-block" and "geolocation" errors to GEOFENCE in the API.
This commit is contained in:
@@ -52,7 +52,7 @@ format-appropriate clean-up to make a single, valid file:
|
|||||||
`‎` / `‏` bidirectional entities.
|
`‎` / `‏` bidirectional entities.
|
||||||
- **Segment merging**: when a WebVTT or boxed subtitle arrives as many DASH/HLS
|
- **Segment merging**: when a WebVTT or boxed subtitle arrives as many DASH/HLS
|
||||||
segments, unshackle merges the segments back into one continuous document with
|
segments, unshackle merges the segments back into one continuous document with
|
||||||
correct timing.
|
correct timing. A cue that a segment boundary cuts in two comes out as one cue.
|
||||||
- **WebVTT sanitization**: merged WebVTT runs through a repair pipeline that clamps
|
- **WebVTT sanitization**: merged WebVTT runs through a repair pipeline that clamps
|
||||||
negative timestamps to `00:00:00.000`, removes stray cue identifiers (like `Q0`, `Q1`)
|
negative timestamps to `00:00:00.000`, removes stray cue identifiers (like `Q0`, `Q1`)
|
||||||
that confuse downstream parsers, and merges multi-line cues that a service split into
|
that confuse downstream parsers, and merges multi-line cues that a service split into
|
||||||
|
|||||||
@@ -114,6 +114,8 @@ def test_build_error_response_debug_mode_includes_traceback() -> None:
|
|||||||
(TimeoutError("read timeout"), APIErrorCode.NETWORK_ERROR),
|
(TimeoutError("read timeout"), APIErrorCode.NETWORK_ERROR),
|
||||||
(Exception("Not available in your region"), APIErrorCode.GEOFENCE),
|
(Exception("Not available in your region"), APIErrorCode.GEOFENCE),
|
||||||
(Exception("Service is geoblocked in GB"), APIErrorCode.GEOFENCE),
|
(Exception("Service is geoblocked in GB"), APIErrorCode.GEOFENCE),
|
||||||
|
(Exception("Content is geo-blocked."), APIErrorCode.GEOFENCE),
|
||||||
|
(Exception("Manifest request failed: GeoLocationBlocked"), APIErrorCode.GEOFENCE),
|
||||||
(
|
(
|
||||||
Exception("Service is not available in your region (GB)"),
|
Exception("Service is not available in your region (GB)"),
|
||||||
APIErrorCode.GEOFENCE,
|
APIErrorCode.GEOFENCE,
|
||||||
|
|||||||
@@ -351,6 +351,20 @@ def test_segmented_fvtt_parses_and_converts(tmp_path, monkeypatch):
|
|||||||
assert cue_count(out) == 2
|
assert cue_count(out) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_segmented_fttml_joins_a_cue_split_at_a_segment_boundary():
|
||||||
|
data = build_stpp_mp4(
|
||||||
|
ttml_fragment(("00:00:01.000", "00:00:02.000", "Carried")),
|
||||||
|
ttml_fragment(("00:00:02.000", "00:00:03.500", "Carried"), ("00:00:03.500", "00:00:04.000", "Next")),
|
||||||
|
)
|
||||||
|
|
||||||
|
captions = Subtitle.parse(data, Codec.fTTML).get_captions("en")
|
||||||
|
|
||||||
|
assert [(c.start / 1e6, c.end / 1e6, c.get_text()) for c in captions] == [
|
||||||
|
(1.0, 3.5, "Carried"),
|
||||||
|
(3.5, 4.0, "Next"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def test_segmented_fttml_parses_and_converts(tmp_path, monkeypatch):
|
def test_segmented_fttml_parses_and_converts(tmp_path, monkeypatch):
|
||||||
monkeypatch.setattr("unshackle.core.config.config.subtitle", {}, raising=False)
|
monkeypatch.setattr("unshackle.core.config.config.subtitle", {}, raising=False)
|
||||||
frag = (
|
frag = (
|
||||||
@@ -448,8 +462,8 @@ 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:
|
def ttml_fragment(*cues: tuple[str, ...]) -> str:
|
||||||
body = "".join(f'<p begin="{begin}" end="{end}">Line</p>' for begin, end in cues)
|
body = "".join(f'<p begin="{cue[0]}" end="{cue[1]}">{cue[2] if len(cue) > 2 else "Line"}</p>' for cue in cues)
|
||||||
return f'<tt xmlns="http://www.w3.org/ns/ttml" xml:lang="en"><body><div>{body}</div></body></tt>'
|
return f'<tt xmlns="http://www.w3.org/ns/ttml" xml:lang="en"><body><div>{body}</div></body></tt>'
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -208,7 +208,10 @@ def categorize_exception(
|
|||||||
exc_str = f"{exc} {root}".lower()
|
exc_str = f"{exc} {root}".lower()
|
||||||
exc_type = type(root).__name__
|
exc_type = type(root).__name__
|
||||||
|
|
||||||
if any(keyword in exc_str for keyword in ["geofence", "geoblock", "region", "not available in", "territory"]):
|
if any(
|
||||||
|
keyword in exc_str
|
||||||
|
for keyword in ["geofence", "geoblock", "geo-block", "geolocation", "region", "not available in", "territory"]
|
||||||
|
):
|
||||||
return APIError(
|
return APIError(
|
||||||
error_code=APIErrorCode.GEOFENCE,
|
error_code=APIErrorCode.GEOFENCE,
|
||||||
message=f"Content not available in your region: {redact_secrets(str(exc))}",
|
message=f"Content not available in your region: {redact_secrets(str(exc))}",
|
||||||
|
|||||||
@@ -376,6 +376,14 @@ class Subtitle(Track):
|
|||||||
boxes = MP4.parse_stream(BytesIO(data))
|
boxes = MP4.parse_stream(BytesIO(data))
|
||||||
return [box.data for box in boxes if box.type == b"mdat"]
|
return [box.data for box in boxes if box.type == b"mdat"]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def append_cue(kept: CaptionList, caption: Caption) -> None:
|
||||||
|
"""Append caption to kept, or extend the last cue that a segment boundary cut and caption carries on."""
|
||||||
|
if kept and kept[-1].end == caption.start and kept[-1].get_text() == caption.get_text():
|
||||||
|
kept[-1].end = caption.end
|
||||||
|
else:
|
||||||
|
kept.append(caption)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def first_cue_start(caption_set: pycaption.CaptionSet) -> Optional[int]:
|
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."""
|
"""The start of the first cue, in microseconds, looking past empty languages; None with no cues."""
|
||||||
@@ -413,10 +421,8 @@ class Subtitle(Track):
|
|||||||
for caption in fragment.get_captions(language):
|
for caption in fragment.get_captions(language):
|
||||||
caption.start = max(caption.start + shift, 0)
|
caption.start = max(caption.start + shift, 0)
|
||||||
caption.end += shift
|
caption.end += shift
|
||||||
if kept and kept[-1].end == caption.start and kept[-1].get_text() == caption.get_text():
|
if caption.end > 0:
|
||||||
kept[-1].end = caption.end # a cue carried into the next fragment
|
Subtitle.append_cue(kept, caption)
|
||||||
elif caption.end > 0:
|
|
||||||
kept.append(caption)
|
|
||||||
if not any(captions.values()):
|
if not any(captions.values()):
|
||||||
logging.getLogger("Subtitle").debug(f"ISM text: {len(payloads)} fragments, {len(times)} fragment times")
|
logging.getLogger("Subtitle").debug(f"ISM text: {len(payloads)} fragments, {len(times)} fragment times")
|
||||||
self.convert(Subtitle.Codec.TimedTextMarkupLang)
|
self.convert(Subtitle.Codec.TimedTextMarkupLang)
|
||||||
@@ -837,7 +843,8 @@ class Subtitle(Track):
|
|||||||
for payload in Subtitle.mdat_payloads(data)
|
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))
|
for caption in segment.get_captions(lang):
|
||||||
|
Subtitle.append_cue(caption_lists[lang], caption)
|
||||||
caption_set = pycaption.CaptionSet(caption_lists)
|
caption_set = pycaption.CaptionSet(caption_lists)
|
||||||
elif codec == Subtitle.Codec.TimedTextMarkupLang:
|
elif codec == Subtitle.Codec.TimedTextMarkupLang:
|
||||||
text = try_ensure_utf8(data).decode("utf8")
|
text = try_ensure_utf8(data).decode("utf8")
|
||||||
@@ -1109,12 +1116,7 @@ class Subtitle(Track):
|
|||||||
nodes=nodes,
|
nodes=nodes,
|
||||||
layout_info=layout,
|
layout_info=layout,
|
||||||
)
|
)
|
||||||
p_caption = captions[-1] if captions else None
|
Subtitle.append_cue(captions, caption)
|
||||||
if p_caption and caption.start == p_caption.end and str(caption.nodes) == str(p_caption.nodes):
|
|
||||||
# it's a duplicate, but lets take its end time
|
|
||||||
p_caption.end = caption.end
|
|
||||||
continue
|
|
||||||
captions.append(caption)
|
|
||||||
|
|
||||||
return captions, language
|
return captions, language
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user